From 40fc230f06cd6373f4b61cd30cf2b1e5d2b7dff2 Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:15:58 +0000 Subject: [PATCH] core: link notices --- apps/ios/Shared/Model/AppAPITypes.swift | 21 +- apps/ios/Shared/Model/SimpleXAPI.swift | 34 +-- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 16 +- .../Chat/ComposeMessage/ComposeView.swift | 4 +- .../ContextPendingMemberActionsView.swift | 15 +- .../Views/Chat/Group/GroupChatInfoView.swift | 196 +++++++++++++--- .../Views/ChatList/ChatListNavLink.swift | 14 +- .../Views/Contacts/ContactListNavLink.swift | 2 +- apps/ios/Shared/Views/Helpers/AppSheet.swift | 8 +- .../Shared/Views/NewChat/NewChatView.swift | 53 +++++ apps/ios/SimpleXChat/APITypes.swift | 1 + apps/ios/SimpleXChat/ChatTypes.swift | 31 +-- .../chat/simplex/common/model/ChatModel.kt | 11 +- .../chat/simplex/common/model/SimpleXAPI.kt | 51 +++-- .../simplex/common/views/chat/ChatInfoView.kt | 15 +- .../ComposeContextPendingMemberActionsView.kt | 17 +- .../simplex/common/views/chat/ComposeView.kt | 3 +- .../views/chat/group/GroupChatInfoView.kt | 215 +++++++++++------- .../views/chat/group/GroupMemberInfoView.kt | 73 ++---- .../common/views/newchat/ConnectPlan.kt | 39 ++++ .../commonMain/resources/MR/base/strings.xml | 19 ++ .../src/Directory/Service.hs | 3 +- bots/api/COMMANDS.md | 14 +- bots/api/TYPES.md | 25 ++ bots/src/API/Docs/Commands.hs | 4 +- bots/src/API/Docs/Types.hs | 2 + docs/protocol/simplex-chat.md | 10 +- .../types/typescript/src/commands.ts | 6 +- .../types/typescript/src/types.ts | 29 +++ .../typescript/src/client.ts | 8 +- packages/simplex-chat-nodejs/src/api.ts | 8 +- .../src/simplex_chat/types/_commands.py | 6 +- .../src/simplex_chat/types/_types.py | 28 ++- simplex-chat.cabal | 2 + src/Simplex/Chat/Controller.hs | 9 +- src/Simplex/Chat/Library/Commands.hs | 74 ++++-- src/Simplex/Chat/Library/Subscriber.hs | 63 ++--- src/Simplex/Chat/Protocol.hs | 42 ++-- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Store/Postgres/Migrations/chat_schema.sql | 31 +++ src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../SQLite/Migrations/chat_query_plans.txt | 33 +++ .../Store/SQLite/Migrations/chat_schema.sql | 9 + src/Simplex/Chat/Store/Shared.hs | 51 ++++- src/Simplex/Chat/View.hs | 8 + tests/ChatTests/Direct.hs | 13 ++ tests/ChatTests/Groups.hs | 192 ++++++++++++++++ tests/ChatTests/Profiles.hs | 109 +++++++++ tests/ChatTests/Utils.hs | 21 ++ tests/ProtocolTests.hs | 36 ++- 50 files changed, 1329 insertions(+), 353 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index eadca9f71e..c4650943dc 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -82,7 +82,7 @@ enum ChatCommand: ChatCmdProtocol { case apiDeleteMemberSupportChat(groupId: Int64, groupMemberId: Int64) case apiMembersRole(groupId: Int64, memberIds: [Int64], memberRole: GroupMemberRole) case apiBlockMembersForAll(groupId: Int64, memberIds: [Int64], blocked: Bool) - case apiRemoveMembers(groupId: Int64, memberIds: [Int64], withMessages: Bool) + case apiRemoveMembers(groupId: Int64, memberIds: [Int64], withMessages: Bool, notice: LinkNotice?) case apiLeaveGroup(groupId: Int64) case apiListMembers(groupId: Int64) case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile) @@ -142,7 +142,7 @@ enum ChatCommand: ChatCmdProtocol { case apiConnectPreparedGroup(groupId: Int64, incognito: Bool, msg: MsgContent?) case apiConnect(userId: Int64, incognito: Bool, connLink: CreatedConnLink) case apiConnectContactViaAddress(userId: Int64, incognito: Bool, contactId: Int64) - case apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode) + case apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode, notice: LinkNotice?) case apiClearChat(type: ChatType, id: Int64) case apiListContacts(userId: Int64) case apiUpdateProfile(userId: Int64, profile: Profile) @@ -263,7 +263,7 @@ enum ChatCommand: ChatCmdProtocol { let msgs = encodeJSON(composedMessages) return "/_create *\(noteFolderId) json \(msgs)" case let .apiReportMessage(groupId, chatItemId, reportReason, reportText): - return "/_report #\(groupId) \(chatItemId) reason=\(reportReason) \(reportText)" + return "/_report #\(groupId) \(chatItemId) reason=\(reportReason.rawValue) \(reportText)" case let .apiUpdateChatItem(type, id, scope, itemId, um, live): return "/_update item \(ref(type, id, scope: scope)) \(itemId) live=\(onOff(live)) \(um.cmdString)" case let .apiDeleteChatItem(type, id, scope, itemIds, mode): return "/_delete item \(ref(type, id, scope: scope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)" case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))" @@ -299,7 +299,7 @@ enum ChatCommand: ChatCmdProtocol { case let .apiDeleteMemberSupportChat(groupId, groupMemberId): return "/_delete member chat #\(groupId) \(groupMemberId)" case let .apiMembersRole(groupId, memberIds, memberRole): return "/_member role #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) \(memberRole.rawValue)" case let .apiBlockMembersForAll(groupId, memberIds, blocked): return "/_block #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) blocked=\(onOff(blocked))" - case let .apiRemoveMembers(groupId, memberIds, withMessages): return "/_remove #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) messages=\(onOff(withMessages))" + case let .apiRemoveMembers(groupId, memberIds, withMessages, notice): return "/_remove #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) messages=\(onOff(withMessages))\(noticeCmdString(notice))" case let .apiLeaveGroup(groupId): return "/_leave #\(groupId)" case let .apiListMembers(groupId): return "/_members #\(groupId)" case let .apiUpdateGroupProfile(groupId, groupProfile): return "/_group_profile #\(groupId) \(encodeJSON(groupProfile))" @@ -371,7 +371,7 @@ enum ChatCommand: ChatCmdProtocol { case let .apiConnectPreparedGroup(groupId, incognito, mc): return "/_connect group #\(groupId) incognito=\(onOff(incognito))\(maybeContent(mc))" case let .apiConnect(userId, incognito, connLink): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connLink.connFullLink) \(connLink.connShortLink ?? "")" case let .apiConnectContactViaAddress(userId, incognito, contactId): return "/_connect contact \(userId) incognito=\(onOff(incognito)) \(contactId)" - case let .apiDeleteChat(type, id, chatDeleteMode): return "/_delete \(ref(type, id, scope: nil)) \(chatDeleteMode.cmdString)" + case let .apiDeleteChat(type, id, chatDeleteMode, notice): return "/_delete \(ref(type, id, scope: nil)) \(chatDeleteMode.cmdString)\(noticeCmdString(notice))" case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id, scope: nil))" case let .apiListContacts(userId): return "/_contacts \(userId)" case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))" @@ -1478,6 +1478,7 @@ enum ContactAddressPlan: Decodable, Hashable { case connectingProhibit(contact: Contact) case known(contact: Contact) case contactViaAddress(contact: Contact) + case linkNotice(expiresAt: Date?, reason: ReportReason?) } public struct GroupShortLinkInfo: Decodable, Hashable { @@ -1494,6 +1495,7 @@ enum GroupLinkPlan: Decodable, Hashable { case known(groupInfo: GroupInfo) case noRelays(groupSLinkData_: GroupShortLinkData?) case updateRequired(groupSLinkData_: GroupShortLinkData?) + case linkNotice(expiresAt: Date?, reason: ReportReason?) } struct ChatTagData: Encodable { @@ -1510,6 +1512,15 @@ struct UpdatedMessage: Encodable { } } +struct LinkNotice: Encodable { + var ttl: Int64? + var reason: ReportReason? +} + +func noticeCmdString(_ notice: LinkNotice?) -> String { + notice.map { " notice=\(encodeJSON($0))" } ?? "" +} + enum ChatDeleteMode: Codable { case full(notify: Bool) case entity(notify: Bool) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 742ef20688..839ef15faa 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1079,7 +1079,7 @@ func apiConnect(incognito: Bool, connLink: CreatedConnLink) async -> (ConnReqTyp return nil } -private func apiConnectResponseAlert(_ r: APIResult) async { +private func apiConnectResponseAlert(_ r: APIResult, _ noticeTarget: LinkNoticeTarget = .link) async { await MainActor.run { switch r.unexpected { case .error(.invalidConnReq): @@ -1092,6 +1092,8 @@ private func apiConnectResponseAlert(_ r: APIResult) async { NSLocalizedString("Unsupported connection link", comment: ""), message: NSLocalizedString("This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link.", comment: "") ) + case let .error(.linkNotice(expiresAt, reason)): + showLinkNoticeAlert(noticeTarget, expiresAt: expiresAt, reason: reason) case let .error(.simplexDomainNotReady(domain, err)): switch err { case .noValidLink: @@ -1231,14 +1233,14 @@ func apiChangePreparedGroupUser(groupId: Int64, newUserId: Int64) async throws - func apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgContent?) async -> Contact? { let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnectPreparedContact(contactId: contactId, incognito: incognito, msg: msg)) if case let .result(.startedConnectionToContact(_, contact)) = r { return contact } - if let r { await apiConnectResponseAlert(r) } + if let r { await apiConnectResponseAlert(r, .address) } return nil } -func apiConnectPreparedGroup(groupId: Int64, incognito: Bool, msg: MsgContent?) async -> (GroupInfo, [RelayConnectionResult])? { +func apiConnectPreparedGroup(groupId: Int64, isChannel: Bool, incognito: Bool, msg: MsgContent?) async -> (GroupInfo, [RelayConnectionResult])? { let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnectPreparedGroup(groupId: groupId, incognito: incognito, msg: msg)) if case let .result(.startedConnectionToGroup(_, groupInfo, relayResults)) = r { return (groupInfo, relayResults) } - if let r { await apiConnectResponseAlert(r) } + if let r { await apiConnectResponseAlert(r, isChannel ? .channel : .group) } return nil } @@ -1251,23 +1253,29 @@ func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> Con if case let .result(.sentInvitationToContact(_, contact, _)) = r { return contact } if let r { logger.error("apiConnectContactViaAddress error: \(responseError(r.unexpected))") - await MainActor.run { showAlert(connectionErrorAlert(r)) } + await MainActor.run { + if case let .error(.linkNotice(expiresAt, reason)) = r.unexpected { + showLinkNoticeAlert(.address, expiresAt: expiresAt, reason: reason) + } else { + showAlert(connectionErrorAlert(r)) + } + } } return nil } -func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws { +func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true), notice: LinkNotice? = nil) async throws { let chatId = type.rawValue + id.description DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) } defer { DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } } - let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false) + let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode, notice: notice), bgTask: false) if case .direct = type, case .contactDeleted = r { return } if case .contactConnection = type, case .contactConnectionDeleted = r { return } if case .group = type, case .groupDeletedUser = r { return } throw r.unexpected } -func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws -> Contact { +func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true), notice: LinkNotice? = nil) async throws -> Contact { let type: ChatType = .direct let chatId = type.rawValue + id.description if case .full = chatDeleteMode { @@ -1278,7 +1286,7 @@ func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } } } - let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false) + let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode, notice: notice), bgTask: false) if case let .contactDeleted(_, contact) = r { return contact } throw r.unexpected } @@ -1297,10 +1305,10 @@ func deleteChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: tru } } -func deleteContactChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async -> Alert? { +func deleteContactChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true), notice: LinkNotice? = nil) async -> Alert? { do { let cInfo = chat.chatInfo - let ct = try await apiDeleteContact(id: cInfo.apiId, chatDeleteMode: chatDeleteMode) + let ct = try await apiDeleteContact(id: cInfo.apiId, chatDeleteMode: chatDeleteMode, notice: notice) await MainActor.run { switch chatDeleteMode { case .full: @@ -2022,8 +2030,8 @@ func apiDeleteMemberSupportChat(_ groupId: Int64, _ groupMemberId: Int64) async throw r.unexpected } -func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool) async throws -> (GroupInfo, [GroupMember]) { - let r: ChatResponse2 = try await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds, withMessages: withMessages), bgTask: false) +func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool, notice: LinkNotice? = nil) async throws -> (GroupInfo, [GroupMember]) { + let r: ChatResponse2 = try await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds, withMessages: withMessages, notice: notice), bgTask: false) if case let .userDeletedMembers(_, updatedGroupInfo, members, _withMessages) = r { return (updatedGroupInfo, members) } throw r.unexpected } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 3d1e7ef477..f8db4978ca 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -1179,7 +1179,8 @@ private func deleteContactOrConversationDialog( showAlert: showAlert ) ) }, - id: "DeleteActiveContactDialog" + id: "DeleteActiveContactDialog", + fraction: contact.activeConn?.viaUserContactLink == nil ? 0.4 : 0.55 )) }, .cancel() @@ -1193,11 +1194,12 @@ private func deleteContactMaybeErrorAlert( _ chat: Chat, _ contact: Contact, chatDeleteMode: ChatDeleteMode, + notice: LinkNotice? = nil, _ dismissToChatList: Bool, _ showAlert: @escaping (SomeAlert) -> Void ) { Task { - let alert_ = await deleteContactChat(chat, chatDeleteMode: chatDeleteMode) + let alert_ = await deleteContactChat(chat, chatDeleteMode: chatDeleteMode, notice: notice) if let alert = alert_ { showAlert(SomeAlert(alert: alert, id: "deleteContactMaybeErrorAlert, error")) } else { @@ -1267,6 +1269,8 @@ struct DeleteActiveContactDialog: View { var dismissToChatList: Bool var showAlert: (SomeAlert) -> Void @State private var keepConversation = false + @State private var ban: LinkBan = .no + @State private var reason: ReportReason = .spam var body: some View { NavigationView { @@ -1274,16 +1278,20 @@ struct DeleteActiveContactDialog: View { Section { Toggle("Keep conversation", isOn: $keepConversation) + if contact.activeConn?.viaUserContactLink != nil { + linkBanRows("Ban from connecting", $ban, $reason) + } + Button(role: .destructive) { dismiss() - deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false), dismissToChatList, showAlert) + deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false), notice: ban.notice(reason), dismissToChatList, showAlert) } label: { Text("Delete without notification") } Button(role: .destructive) { dismiss() - deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: true), dismissToChatList, showAlert) + deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: true), notice: ban.notice(reason), dismissToChatList, showAlert) } label: { Text("Delete and notify contact") } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index a1570ba4a1..dc8613d6c7 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -1208,7 +1208,7 @@ struct ComposeView: View { await sending() let mc = connectCheckLinkPreview() let incognito = chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault - if let (groupInfo, relayResults) = await apiConnectPreparedGroup(groupId: chat.chatInfo.apiId, incognito: incognito, msg: mc) { + if let (groupInfo, relayResults) = await apiConnectPreparedGroup(groupId: chat.chatInfo.apiId, isChannel: chat.chatInfo.isChannel, incognito: incognito, msg: mc) { await MainActor.run { self.chatModel.updateGroup(groupInfo) self.chatModel.channelRelayHostnames.removeValue(forKey: groupInfo.groupId) @@ -1421,7 +1421,7 @@ struct ComposeView: View { case .spam: NSLocalizedString("Report spam: only group moderators will see it.", comment: "report reason") case .profile: NSLocalizedString("Report member profile: only group moderators will see it.", comment: "report reason") case .community: NSLocalizedString("Report violation: only group moderators will see it.", comment: "report reason") - case .illegal: NSLocalizedString("Report content: only group moderators will see it.", comment: "report reason") + case .content: NSLocalizedString("Report content: only group moderators will see it.", comment: "report reason") case .other: NSLocalizedString("Report other: only group moderators will see it.", comment: "report reason") case .unknown: "" // Should never happen } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift index e9913053ea..51ed07b23a 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift @@ -45,11 +45,16 @@ struct ContextPendingMemberActionsView: View { } func showRejectMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismiss: DismissAction? = nil) { - showAlert( - title: NSLocalizedString("Reject member?", comment: "alert title"), - buttonTitle: "Reject", - buttonAction: { removeMember(groupInfo, member, withMessages: false, dismiss: dismiss) }, - cancelButton: true + showMemberBanAlert( + groupInfo, + member, + NSLocalizedString("Reject member?", comment: "alert title"), + message: nil, + actions: [ + LinkBanAction(title: NSLocalizedString("Reject", comment: "alert action")) { notice in + removeMember(groupInfo, member, withMessages: false, notice: notice, dismiss: dismiss) + } + ] ) } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 1be8070259..2d8382cc35 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -874,7 +874,14 @@ struct GroupChatInfoView: View { @ViewBuilder private func deleteGroupButton() -> some View { let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel" : groupInfo.businessChat == nil ? "Delete group" : "Delete chat" Button(role: .destructive) { - alert = .deleteGroupAlert + if canBanOnChatDeletion(groupInfo) { + showDeleteBusinessChatAlert(chat) { + dismiss() + chatModel.chatId = nil + } + } else { + alert = .deleteGroupAlert + } } label: { Label(label, systemImage: "trash") .foregroundColor(Color.red) @@ -1010,43 +1017,54 @@ func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: cancelAlertAction ]} ) - } else if groupInfo.useRelays { - showAlert( - NSLocalizedString("Remove subscriber?", comment: "alert title"), - message: NSLocalizedString("Subscriber will be removed from channel - this cannot be undone!", comment: "alert message"), - actions: {[ - UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in - removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss) - }, - UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in - removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss) - }, - cancelAlertAction - ]} - ) } else { - showAlert( - NSLocalizedString("Remove member?", comment: "alert title"), - message: groupInfo.businessChat == nil + showMemberBanAlert( + groupInfo, + mem, + groupInfo.useRelays + ? NSLocalizedString("Remove subscriber?", comment: "alert title") + : NSLocalizedString("Remove member?", comment: "alert title"), + message: groupInfo.useRelays + ? NSLocalizedString("Subscriber will be removed from channel - this cannot be undone!", comment: "alert message") + : groupInfo.businessChat == nil ? NSLocalizedString("Member will be removed from group - this cannot be undone!", comment: "alert message") : NSLocalizedString("Member will be removed from chat - this cannot be undone!", comment: "alert message"), - actions: {[ - UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in - removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss) + actions: [ + LinkBanAction(title: NSLocalizedString("Remove", comment: "alert action")) { notice in + removeMember(groupInfo, mem, withMessages: false, notice: notice, dismiss: dismiss) }, - UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in - removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss) - }, - cancelAlertAction - ]} + LinkBanAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action")) { notice in + removeMember(groupInfo, mem, withMessages: true, notice: notice, dismiss: dismiss) + } + ] ) } } -func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, withMessages: Bool, dismiss: DismissAction?) { +func showMemberBanAlert(_ groupInfo: GroupInfo, _ mem: GroupMember, _ title: String, message: String?, actions: [LinkBanAction]) { + if mem.memberStatus == .memInvited { + showAlert(title, message: message) { + actions.map { a in + UIAlertAction(title: a.title, style: .destructive) { _ in a.action(nil) } + } + [cancelAlertAction] + } + } else { + showAppSheet(detents: [.medium(), .large()]) { + LinkBanDialog( + title: title, + message: message, + banLabel: groupInfo.businessChat == nil ? "Ban from joining" : "Ban from connecting", + ban: .day, + actions: actions + ) + } + } +} + +func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, withMessages: Bool, notice: LinkNotice? = nil, dismiss: DismissAction?) { Task { do { - let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId], withMessages) + let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId], withMessages, notice: notice) await MainActor.run { ChatModel.shared.updateGroup(updatedGroupInfo) updatedMembers.forEach { updatedMember in @@ -1069,6 +1087,128 @@ func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, withMessages: Bool } } +enum LinkBan: Identifiable, Hashable { + case no + case hour + case day + case week + case month + case permanent + + static let values: [LinkBan] = [.no, .hour, .day, .week, .month, .permanent] + + var id: Self { self } + + var text: String { + switch self { + case .no: NSLocalizedString("No", comment: "ban duration") + case .hour: NSLocalizedString("1 hour", comment: "ban duration") + case .day: NSLocalizedString("1 day", comment: "ban duration") + case .week: NSLocalizedString("1 week", comment: "ban duration") + case .month: NSLocalizedString("1 month", comment: "ban duration") + case .permanent: NSLocalizedString("Permanently", comment: "ban duration") + } + } + + func notice(_ reason: ReportReason) -> LinkNotice? { + switch self { + case .no: nil + case .hour: LinkNotice(ttl: 3600, reason: reason) + case .day: LinkNotice(ttl: 86400, reason: reason) + case .week: LinkNotice(ttl: 604800, reason: reason) + case .month: LinkNotice(ttl: 2592000, reason: reason) + case .permanent: LinkNotice(ttl: nil, reason: reason) + } + } +} + +struct LinkBanAction { + var title: String + var action: (LinkNotice?) -> Void +} + +@ViewBuilder func linkBanRows(_ label: LocalizedStringKey, _ ban: Binding, _ reason: Binding) -> some View { + WrappedPicker(label, selection: ban) { + ForEach(LinkBan.values) { Text($0.text) } + } + if ban.wrappedValue != .no { + WrappedPicker("Reason", selection: reason) { + ForEach(ReportReason.supportedReasons, id: \.self) { Text($0.text) } + } + } +} + +struct LinkBanDialog: View { + @Environment(\.dismiss) var dismiss + @EnvironmentObject var theme: AppTheme + var title: String + var message: String? + var banLabel: LocalizedStringKey + @State var ban: LinkBan + var actions: [LinkBanAction] + @State private var reason: ReportReason = .spam + + var body: some View { + NavigationView { + List { + Section { + linkBanRows(banLabel, $ban, $reason) + ForEach(actions.indices, id: \.self) { i in + Button(actions[i].title, role: .destructive) { + dismiss() + actions[i].action(ban.notice(reason)) + } + } + Button("Cancel", role: .cancel) { dismiss() } + } footer: { + if let message { + Text(message).foregroundColor(theme.colors.secondary) + } + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .modifier(ThemedBackground(grouped: true)) + } + } +} + +func showDeleteBusinessChatAlert(_ chat: Chat, deleted: (() -> Void)? = nil) { + showAppSheet(detents: [.medium(), .large()]) { + LinkBanDialog( + title: NSLocalizedString("Delete chat?", comment: "alert title"), + message: chat.chatInfo.displayName + "\n\n" + NSLocalizedString("Chat will be deleted for all members - this cannot be undone!", comment: "alert message"), + banLabel: "Ban from connecting", + ban: .no, + actions: [ + LinkBanAction(title: NSLocalizedString("Delete", comment: "alert action")) { notice in + Task { + do { + try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, notice: notice) + await MainActor.run { + deleted?() + ChatModel.shared.removeChat(chat.chatInfo.id) + } + } catch let error { + logger.error("showDeleteBusinessChatAlert apiDeleteChat error: \(responseError(error))") + await MainActor.run { + showAlert( + NSLocalizedString("Error deleting chat!", comment: "alert title"), + message: responseError(error) + ) + } + } + } + } + ] + ) + } +} + +func canBanOnChatDeletion(_ groupInfo: GroupInfo) -> Bool { + groupInfo.businessChat?.chatType == .customer && groupInfo.membership.memberCurrent +} + func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text { groupInfo.useRelays ? ( groupInfo.membership.memberCurrent diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 0ed78401b0..4478ba78cb 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -446,7 +446,7 @@ struct ChatListNavLink: View { if canShowGroupDelete, let gi = groupInfo, gi.canDelete { buttons.append(.destructive(Text("Delete")) { - AlertManager.shared.showAlert(deleteGroupAlert(gi)) + showDeleteGroupAlert(gi) }) } @@ -484,13 +484,23 @@ struct ChatListNavLink: View { private func deleteGroupChatButton(_ groupInfo: GroupInfo) -> some View { Button { - AlertManager.shared.showAlert(deleteGroupAlert(groupInfo)) + showDeleteGroupAlert(groupInfo) } label: { deleteLabel } .tint(.red) } + private func showDeleteGroupAlert(_ groupInfo: GroupInfo) { + if canBanOnChatDeletion(groupInfo) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + showDeleteBusinessChatAlert(chat) + } + } else { + AlertManager.shared.showAlert(deleteGroupAlert(groupInfo)) + } + } + // Spec: spec/client/chat-list.md#contactRequestNavLink private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View { ContactRequestView(contactRequest: contactRequest, chat: chat) diff --git a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift index 9214e3ecde..bbb74bd9a2 100644 --- a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift +++ b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift @@ -43,7 +43,7 @@ struct ContactListNavLink: View { .sheet(item: $sheet) { if #available(iOS 16.0, *) { $0.content - .presentationDetents([.fraction(0.4)]) + .presentationDetents([.fraction($0.fraction)]) } else { $0.content } diff --git a/apps/ios/Shared/Views/Helpers/AppSheet.swift b/apps/ios/Shared/Views/Helpers/AppSheet.swift index 51ae63f07e..113181841e 100644 --- a/apps/ios/Shared/Views/Helpers/AppSheet.swift +++ b/apps/ios/Shared/Views/Helpers/AppSheet.swift @@ -31,13 +31,17 @@ private struct PrivacySensitive: ViewModifier { // Presented from the top view controller instead of a .sheet on a parent view, so an alert button or // a view that is itself in a sheet can open it. -func showAppSheet(@ViewBuilder content: () -> Content) { +func showAppSheet(detents: [UISheetPresentationController.Detent]? = nil, @ViewBuilder content: () -> Content) { if let topController = getTopViewController() { let v = content() .modifier(PrivacySensitive()) .environmentObject(ChatModel.shared) .environmentObject(AppTheme.shared) - topController.present(UIHostingController(rootView: v), animated: true) + let controller = UIHostingController(rootView: v) + if let detents, let sheet = controller.sheetPresentationController { + sheet.detents = detents + } + topController.present(controller, animated: true) } } diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index ef691b10f9..654d2faf9d 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -920,6 +920,46 @@ private func showInvitationLinkConnectingAlert(cleanup: (() -> Void)?) { ) } +enum LinkNoticeTarget { + case group + case channel + case address + case link +} + +func showLinkNoticeAlert(_ target: LinkNoticeTarget, expiresAt: Date?, reason: ReportReason?, cleanup: (() -> Void)? = nil) { + let join = target == .group || target == .channel + let banned = switch target { + case .group: NSLocalizedString("You were banned from this group.", comment: "alert message") + case .channel: NSLocalizedString("You were banned from this channel.", comment: "alert message") + case .address: NSLocalizedString("You were banned from connecting via this address.", comment: "alert message") + case .link: NSLocalizedString("You were banned from connecting via this link.", comment: "alert message") + } + let until = + if let expiresAt { + String.localizedStringWithFormat( + join + ? NSLocalizedString("You can join again after %@.", comment: "alert message") + : NSLocalizedString("You can connect again after %@.", comment: "alert message"), + expiresAt.formatted(date: .abbreviated, time: .shortened) + ) + } else { + join + ? NSLocalizedString("You can't join it again.", comment: "alert message") + : NSLocalizedString("You can't connect again.", comment: "alert message") + } + let reasonLine: String? = + if case .some(.unknown) = reason { nil } + else { reason.map { String.localizedStringWithFormat(NSLocalizedString("Reason: %@.", comment: "alert message"), $0.text) } } + showAlert( + NSLocalizedString("Banned", comment: "alert title"), + message: [banned, until, reasonLine].compactMap { $0 }.joined(separator: "\n"), + actions: {[ + okCleanupAlertAction(cleanup: cleanup) + ]} + ) +} + private func showGroupLinkConnectingAlert(groupInfo: GroupInfo?, cleanup: (() -> Void)?) { if let groupInfo = groupInfo { if groupInfo.businessChat == nil { @@ -1519,6 +1559,11 @@ func planAndConnect( cleanup: cleanup ) } + case let .linkNotice(expiresAt, reason): + logger.debug("planAndConnect, .contactAddress, .linkNotice") + await MainActor.run { + showLinkNoticeAlert(.address, expiresAt: expiresAt, reason: reason, cleanup: cleanup) + } } case let .groupLink(glp): switch glp { @@ -1649,6 +1694,14 @@ func planAndConnect( cleanup?() } } + case let .linkNotice(expiresAt, reason): + logger.debug("planAndConnect, .groupLink, .linkNotice") + let isChannel = + if case .link(_, .channel, _) = strConnectTarget(shortOrFullLink) { true } + else { planSimplexName?.nameType == .publicGroup } + await MainActor.run { + showLinkNoticeAlert(isChannel ? .channel : .group, expiresAt: expiresAt, reason: reason, cleanup: cleanup) + } } case let .error(chatError): logger.debug("planAndConnect, .error \(chatErrorString(chatError))") diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 75d66ebb6f..c243dd587a 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -767,6 +767,7 @@ public enum ChatErrorType: Decodable, Hashable { case unsupportedConnReq case invalidChatMessage(connection: Connection, message: String) case connReqMessageProhibited + case linkNotice(expiresAt: Date?, reason: ReportReason?) case contactNotReady(contact: Contact) case contactNotActive(contact: Contact) case contactDisabled(contact: Contact) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index cf520a65c7..964471a1c3 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2607,6 +2607,7 @@ public struct Connection: Decodable, Hashable { public var peerChatVRange: VersionRange public var connStatus: ConnStatus public var connLevel: Int + public var viaUserContactLink: Int64? public var viaGroupLink: Bool public var customUserProfileId: Int64? public var connectionCode: SecurityCode? @@ -2620,7 +2621,7 @@ public struct Connection: Decodable, Hashable { public var connectionStats: ConnectionStats? = nil private enum CodingKeys: String, CodingKey { - case connId, agentConnId, peerChatVRange, connStatus, connLevel, viaGroupLink, customUserProfileId, connectionCode, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, quotaErrCounter + case connId, agentConnId, peerChatVRange, connStatus, connLevel, viaUserContactLink, viaGroupLink, customUserProfileId, connectionCode, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, quotaErrCounter } public var id: ChatId { get { ":\(connId)" } } @@ -5776,18 +5777,29 @@ public enum FormatColor: String, Decodable, Hashable { public enum ReportReason: Hashable { case spam - case illegal + case content case community case profile case other case unknown(type: String) - public static var supportedReasons: [ReportReason] = [.spam, .illegal, .community, .profile, .other] + public static var supportedReasons: [ReportReason] = [.spam, .content, .community, .profile, .other] + + public var rawValue: String { + switch self { + case .spam: "spam" + case .content: "content" + case .community: "community" + case .profile: "profile" + case .other: "other" + case let .unknown(type): type + } + } public var text: String { switch self { case .spam: return NSLocalizedString("Spam", comment: "report reason") - case .illegal: return NSLocalizedString("Inappropriate content", comment: "report reason") + case .content: return NSLocalizedString("Inappropriate content", comment: "report reason") case .community: return NSLocalizedString("Community guidelines violation", comment: "report reason") case .profile: return NSLocalizedString("Inappropriate profile", comment: "report reason") case .other: return NSLocalizedString("Another reason", comment: "report reason") @@ -5807,14 +5819,7 @@ public enum ReportReason: Hashable { extension ReportReason: Encodable { public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() - switch self { - case .spam: try container.encode("spam") - case .illegal: try container.encode("illegal") - case .community: try container.encode("community") - case .profile: try container.encode("profile") - case .other: try container.encode("other") - case let .unknown(type): try container.encode(type) - } + try container.encode(rawValue) } } @@ -5824,7 +5829,7 @@ extension ReportReason: Decodable { let type = try container.decode(String.self) switch type { case "spam": self = .spam - case "illegal": self = .illegal + case "content", "illegal": self = .content case "community": self = .community case "profile": self = .profile case "other": self = .other diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 9d65606a9c..542415de2c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -2038,6 +2038,7 @@ data class Connection( val peerChatVRange: VersionRange, val connStatus: ConnStatus, val connLevel: Int, + val viaUserContactLink: Long? = null, val viaGroupLink: Boolean, val customUserProfileId: Long? = null, val connectionCode: SecurityCode? = null, @@ -5293,19 +5294,19 @@ enum class FormatColor(val color: String) { @Serializable(with = ReportReasonSerializer::class) sealed class ReportReason { @Serializable @SerialName("spam") object Spam: ReportReason() - @Serializable @SerialName("illegal") object Illegal: ReportReason() + @Serializable @SerialName("content") object Content: ReportReason() @Serializable @SerialName("community") object Community: ReportReason() @Serializable @SerialName("profile") object Profile: ReportReason() @Serializable @SerialName("other") object Other: ReportReason() @Serializable @SerialName("unknown") data class Unknown(val type: String): ReportReason() companion object { - val supportedReasons: List = listOf(Spam, Illegal, Community, Profile, Other) + val supportedReasons: List = listOf(Spam, Content, Community, Profile, Other) } val text: String get() = when (this) { Spam -> generalGetString(MR.strings.report_reason_spam) - Illegal -> generalGetString(MR.strings.report_reason_illegal) + Content -> generalGetString(MR.strings.report_reason_illegal) Community -> generalGetString(MR.strings.report_reason_community) Profile -> generalGetString(MR.strings.report_reason_profile) Other -> generalGetString(MR.strings.report_reason_other) @@ -5320,7 +5321,7 @@ object ReportReasonSerializer : KSerializer { override fun deserialize(decoder: Decoder): ReportReason { return when (val value = decoder.decodeString()) { "spam" -> ReportReason.Spam - "illegal" -> ReportReason.Illegal + "content", "illegal" -> ReportReason.Content "community" -> ReportReason.Community "profile" -> ReportReason.Profile "other" -> ReportReason.Other @@ -5331,7 +5332,7 @@ object ReportReasonSerializer : KSerializer { override fun serialize(encoder: Encoder, value: ReportReason) { val stringValue = when (value) { is ReportReason.Spam -> "spam" - is ReportReason.Illegal -> "illegal" + is ReportReason.Content -> "content" is ReportReason.Community -> "community" is ReportReason.Profile -> "profile" is ReportReason.Other -> "other" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 9100b5ecea..fab9066fd3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -33,6 +33,8 @@ import chat.simplex.common.views.chat.item.showContentBlockedAlert import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert import chat.simplex.common.views.chatlist.openGroupChat import chat.simplex.common.views.migration.MigrationFileLinkData +import chat.simplex.common.views.newchat.LinkNoticeTarget +import chat.simplex.common.views.newchat.showLinkNoticeAlert import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.networkAndServers.defaultConditionsLink @@ -1656,8 +1658,12 @@ object ChatController { return null } - private fun apiConnectResponseAlert(r: API) { + private fun apiConnectResponseAlert(r: API, noticeTarget: LinkNoticeTarget = LinkNoticeTarget.Link) { when { + r is API.Error && r.err is ChatError.ChatErrorChat + && r.err.errorType is ChatErrorType.LinkNotice -> { + showLinkNoticeAlert(noticeTarget, r.err.errorType.expiresAt, r.err.errorType.reason) + } r is API.Error && r.err is ChatError.ChatErrorChat && r.err.errorType is ChatErrorType.InvalidConnReq -> { AlertManager.shared.showAlertMsg( @@ -1818,17 +1824,17 @@ object ChatController { if (r is API.Result && r.res is CR.StartedConnectionToContact) return r.res.contact if (r != null) { Log.e(TAG, "apiConnectPreparedContact bad response: ${r.responseType} ${r.details}") - apiConnectResponseAlert(r) + apiConnectResponseAlert(r, LinkNoticeTarget.Address) } return null } - suspend fun apiConnectPreparedGroup(rh: Long?, groupId: Long, incognito: Boolean, msg: MsgContent?): Pair>? { + suspend fun apiConnectPreparedGroup(rh: Long?, groupId: Long, isChannel: Boolean, incognito: Boolean, msg: MsgContent?): Pair>? { val r = sendCmdWithRetry(rh, CC.APIConnectPreparedGroup(groupId, incognito, msg)) if (r is API.Result && r.res is CR.StartedConnectionToGroup) return Pair(r.res.groupInfo, r.res.relayResults) if (r != null) { Log.e(TAG, "apiConnectPreparedGroup bad response: ${r.responseType} ${r.details}") - apiConnectResponseAlert(r) + apiConnectResponseAlert(r, if (isChannel) LinkNoticeTarget.Channel else LinkNoticeTarget.Group) } return null } @@ -1838,24 +1844,26 @@ object ChatController { val r = sendCmdWithRetry(rh, CC.ApiConnectContactViaAddress(userId, incognito, contactId)) if (r is API.Result && r.res is CR.SentInvitationToContact) return r.res.contact if (r == null) return null - if (!(networkErrorAlert(r))) { + if (r is API.Error && r.err is ChatError.ChatErrorChat && r.err.errorType is ChatErrorType.LinkNotice) { + showLinkNoticeAlert(LinkNoticeTarget.Address, r.err.errorType.expiresAt, r.err.errorType.reason) + } else if (!(networkErrorAlert(r))) { apiErrorAlert("apiConnectContactViaAddress", generalGetString(MR.strings.connection_error), r) } return null } - suspend fun deleteChat(chat: Chat, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)) { + suspend fun deleteChat(chat: Chat, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true), notice: LinkNotice? = null) { val cInfo = chat.chatInfo - if (apiDeleteChat(rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, chatDeleteMode = chatDeleteMode)) { + if (apiDeleteChat(rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, chatDeleteMode = chatDeleteMode, notice = notice)) { withContext(Dispatchers.Main) { chatModel.chatsContext.removeChat(chat.remoteHostId, cInfo.id) } } } - suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)): Boolean { + suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true), notice: LinkNotice? = null): Boolean { chatModel.deletedChats.value += rh to type.type + id - val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode)) + val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode, notice)) val res = r.result val success = when { res is CR.ContactDeleted && type == ChatType.Direct -> true @@ -1877,10 +1885,10 @@ object ChatController { return success } - suspend fun apiDeleteContact(rh: Long?, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)): Contact? { + suspend fun apiDeleteContact(rh: Long?, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true), notice: LinkNotice? = null): Contact? { val type = ChatType.Direct chatModel.deletedChats.value += rh to type.type + id - val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode)) + val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode, notice)) val contact = if (r is API.Result && r.res is CR.ContactDeleted) { r.res.contact } else { @@ -2449,8 +2457,8 @@ object ChatController { return null } - suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean): Pair>? { - val r = sendCmd(rh, CC.ApiRemoveMembers(groupId, memberIds, withMessages)) + suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean, notice: LinkNotice? = null): Pair>? { + val r = sendCmd(rh, CC.ApiRemoveMembers(groupId, memberIds, withMessages, notice)) if (r is API.Result && r.res is CR.UserDeletedMembers) return r.res.groupInfo to r.res.members if (!(networkErrorAlert(r))) { apiErrorAlert("apiRemoveMembers", generalGetString(MR.strings.error_removing_member), r) @@ -3949,7 +3957,7 @@ sealed class CC { class ApiDeleteMemberSupportChat(val groupId: Long, val groupMemberId: Long): CC() class ApiMembersRole(val groupId: Long, val memberIds: List, val memberRole: GroupMemberRole): CC() class ApiBlockMembersForAll(val groupId: Long, val memberIds: List, val blocked: Boolean): CC() - class ApiRemoveMembers(val groupId: Long, val memberIds: List, val withMessages: Boolean): CC() + class ApiRemoveMembers(val groupId: Long, val memberIds: List, val withMessages: Boolean, val notice: LinkNotice?): CC() class ApiLeaveGroup(val groupId: Long): CC() class ApiListMembers(val groupId: Long): CC() class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC() @@ -4009,7 +4017,7 @@ sealed class CC { class APIConnectPreparedGroup(val groupId: Long, val incognito: Boolean, val msg: MsgContent?): CC() class APIConnect(val userId: Long, val incognito: Boolean, val connLink: CreatedConnLink): CC() class ApiConnectContactViaAddress(val userId: Long, val incognito: Boolean, val contactId: Long): CC() - class ApiDeleteChat(val type: ChatType, val id: Long, val chatDeleteMode: ChatDeleteMode): CC() + class ApiDeleteChat(val type: ChatType, val id: Long, val chatDeleteMode: ChatDeleteMode, val notice: LinkNotice?): CC() class ApiClearChat(val type: ChatType, val id: Long): CC() class ApiListContacts(val userId: Long): CC() class ApiUpdateProfile(val userId: Long, val profile: Profile): CC() @@ -4165,7 +4173,7 @@ sealed class CC { is ApiDeleteMemberSupportChat -> "/_delete member chat #$groupId $groupMemberId" is ApiMembersRole -> "/_member role #$groupId ${memberIds.joinToString(",")} ${memberRole.memberRole}" is ApiBlockMembersForAll -> "/_block #$groupId ${memberIds.joinToString(",")} blocked=${onOff(blocked)}" - is ApiRemoveMembers -> "/_remove #$groupId ${memberIds.joinToString(",")} messages=${onOff(withMessages)}" + is ApiRemoveMembers -> "/_remove #$groupId ${memberIds.joinToString(",")} messages=${onOff(withMessages)}${maybeNotice(notice)}" is ApiLeaveGroup -> "/_leave #$groupId" is ApiListMembers -> "/_members #$groupId" is ApiUpdateGroupProfile -> "/_group_profile #$groupId ${json.encodeToString(groupProfile)}" @@ -4228,7 +4236,7 @@ sealed class CC { is APIConnectPreparedGroup -> "/_connect group #$groupId incognito=${onOff(incognito)}${maybeContent(msg)}" is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} ${connLink.connFullLink} ${connLink.connShortLink ?: ""}" is ApiConnectContactViaAddress -> "/_connect contact $userId incognito=${onOff(incognito)} $contactId" - is ApiDeleteChat -> "/_delete ${chatRef(type, id, scope = null)} ${chatDeleteMode.cmdString}" + is ApiDeleteChat -> "/_delete ${chatRef(type, id, scope = null)} ${chatDeleteMode.cmdString}${maybeNotice(notice)}" is ApiClearChat -> "/_clear chat ${chatRef(type, id, scope = null)}" is ApiListContacts -> "/_contacts $userId" is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}" @@ -4521,6 +4529,8 @@ sealed class CC { } } + private fun maybeNotice(notice: LinkNotice?): String = if (notice == null) "" else " notice=" + json.encodeToString(notice) + companion object { fun chatRef(chatType: ChatType, id: Long, scope: GroupChatScope?) = when (scope) { null -> "${chatType.type}${id}" @@ -7294,6 +7304,9 @@ sealed class ChatDeleteMode { } } +@Serializable +data class LinkNotice(val ttl: Long? = null, val reason: ReportReason? = null) + @Serializable data class CreatedConnLink(val connFullLink: String, val connShortLink: String?) { fun simplexChatUri(short: Boolean): String = @@ -7462,6 +7475,7 @@ sealed class ContactAddressPlan { @Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val contact: Contact): ContactAddressPlan() @Serializable @SerialName("known") class Known(val contact: Contact): ContactAddressPlan() @Serializable @SerialName("contactViaAddress") class ContactViaAddress(val contact: Contact): ContactAddressPlan() + @Serializable @SerialName("linkNotice") class LinkNotice(val expiresAt: Instant? = null, val reason: ReportReason? = null): ContactAddressPlan() } @Serializable @@ -7473,6 +7487,7 @@ sealed class GroupLinkPlan { @Serializable @SerialName("known") class Known(val groupInfo: GroupInfo): GroupLinkPlan() @Serializable @SerialName("noRelays") class NoRelays(val groupSLinkData_: GroupShortLinkData? = null): GroupLinkPlan() @Serializable @SerialName("updateRequired") class UpdateRequired(val groupSLinkData_: GroupShortLinkData? = null): GroupLinkPlan() + @Serializable @SerialName("linkNotice") class LinkNotice(val expiresAt: Instant? = null, val reason: ReportReason? = null): GroupLinkPlan() } abstract class TerminalItem { @@ -7780,6 +7795,7 @@ sealed class ChatErrorType { is UnsupportedConnReq -> "unsupportedConnReq" is InvalidChatMessage -> "invalidChatMessage" is ConnReqMessageProhibited -> "connReqMessageProhibited" + is LinkNotice -> "linkNotice" is ContactNotReady -> "contactNotReady" is ContactNotActive -> "contactNotActive" is ContactDisabled -> "contactDisabled" @@ -7865,6 +7881,7 @@ sealed class ChatErrorType { @Serializable @SerialName("unsupportedConnReq") object UnsupportedConnReq: ChatErrorType() @Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType() @Serializable @SerialName("connReqMessageProhibited") object ConnReqMessageProhibited: ChatErrorType() + @Serializable @SerialName("linkNotice") class LinkNotice(val expiresAt: Instant? = null, val reason: ReportReason? = null): ChatErrorType() @Serializable @SerialName("contactNotReady") class ContactNotReady(val contact: Contact): ChatErrorType() @Serializable @SerialName("contactNotActive") class ContactNotActive(val contact: Contact): ChatErrorType() @Serializable @SerialName("contactDisabled") class ContactDisabled(val contact: Contact): ChatErrorType() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 91b3270dea..2323582c30 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -40,6 +40,8 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.platform.* import chat.simplex.common.views.chat.group.ChatTTLOption +import chat.simplex.common.views.chat.group.LinkBan +import chat.simplex.common.views.chat.group.LinkBanRows import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.chatlist.updateChatSettings import chat.simplex.common.views.newchat.* @@ -305,6 +307,8 @@ sealed class ContactDeleteMode { private fun deleteActiveContactDialog(chat: Chat, contact: Contact, chatModel: ChatModel, close: (() -> Unit)? = null) { val contactDeleteMode = mutableStateOf(ContactDeleteMode.Full()) + val ban = mutableStateOf(LinkBan.No) + val reason = mutableStateOf(ReportReason.Spam) AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.delete_contact_question), @@ -330,10 +334,13 @@ private fun deleteActiveContactDialog(chat: Chat, contact: Contact, chatModel: C ) } } + if (contact.activeConn?.viaUserContactLink != null) { + LinkBanRows(stringResource(MR.strings.link_ban_from_connecting), ban, reason) + } // Delete without notification SectionItemView({ AlertManager.shared.hideAlert() - deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.value.toChatDeleteMode(notify = false)) + deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.value.toChatDeleteMode(notify = false), notice = ban.value.notice(reason.value)) if (contactDeleteMode.value is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) { showDeleteContactNotice(contact) } @@ -343,7 +350,7 @@ private fun deleteActiveContactDialog(chat: Chat, contact: Contact, chatModel: C // Delete contact and notify SectionItemView({ AlertManager.shared.hideAlert() - deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.value.toChatDeleteMode(notify = true)) + deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.value.toChatDeleteMode(notify = true), notice = ban.value.notice(reason.value)) if (contactDeleteMode.value is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) { showDeleteContactNotice(contact) } @@ -468,11 +475,11 @@ private fun showDeleteContactNotice(contact: Contact) { ) } -fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)) { +fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true), notice: LinkNotice? = null) { val chatInfo = chat.chatInfo withBGApi { val chatRh = chat.remoteHostId - val ct = chatModel.controller.apiDeleteContact(chatRh, chatInfo.apiId, chatDeleteMode) + val ct = chatModel.controller.apiDeleteContact(chatRh, chatInfo.apiId, chatDeleteMode, notice) if (ct != null) { withContext(Dispatchers.Main) { when (chatDeleteMode) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt index be82a5d2d3..644e613350 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt @@ -13,8 +13,10 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.chat.group.LinkBanAction import chat.simplex.common.views.chat.group.removeMember import chat.simplex.common.views.chat.group.removeMemberDialog +import chat.simplex.common.views.chat.group.showMemberBanAlert import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource @@ -71,13 +73,16 @@ fun ComposeContextPendingMemberActionsView( } fun rejectMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { - AlertManager.shared.showAlertDialog( + showMemberBanAlert( + groupInfo, + showBan = true, title = generalGetString(MR.strings.reject_pending_member_alert_title), - confirmText = generalGetString(MR.strings.reject_pending_member_button), - onConfirm = { - removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close) - }, - destructive = true, + text = null, + actions = listOf( + LinkBanAction(generalGetString(MR.strings.reject_pending_member_button)) { notice -> + removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close, notice) + } + ) ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index c81149fcb1..be346ab9e7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -697,6 +697,7 @@ fun ComposeView( val result = chatModel.controller.apiConnectPreparedGroup( rh = chat.remoteHostId, groupId = chat.chatInfo.apiId, + isChannel = chat.chatInfo.isChannel, incognito = incognito, msg = mc ) @@ -1238,7 +1239,7 @@ fun ComposeView( fun ReportReasonView(reason: ReportReason) { val reportText = when (reason) { is ReportReason.Spam -> generalGetString(MR.strings.report_compose_reason_header_spam) - is ReportReason.Illegal -> generalGetString(MR.strings.report_compose_reason_header_illegal) + is ReportReason.Content -> generalGetString(MR.strings.report_compose_reason_header_illegal) is ReportReason.Profile -> generalGetString(MR.strings.report_compose_reason_header_profile) is ReportReason.Community -> generalGetString(MR.strings.report_compose_reason_header_community) is ReportReason.Other -> generalGetString(MR.strings.report_compose_reason_header_other) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 49e7ac6db7..d2308a1bbf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -221,29 +221,41 @@ fun deleteGroupDialog(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, cl if (groupInfo.membership.memberCurrent) MR.strings.delete_chat_for_all_members_cannot_undo_warning else MR.strings.delete_chat_for_self_cannot_undo_warning } - AlertManager.shared.showAlertDialog( - title = generalGetString(titleId), - text = "${groupInfo.displayName}\n\n${generalGetString(messageId)}", - parseHtml = false, - confirmText = generalGetString(MR.strings.delete_verb), - onConfirm = { - withBGApi { - val r = chatModel.controller.apiDeleteChat(chat.remoteHostId, chatInfo.chatType, chatInfo.apiId) - if (r) { - withContext(Dispatchers.Main) { - chatModel.chatsContext.removeChat(chat.remoteHostId, chatInfo.id) - if (chatModel.chatId.value == chatInfo.id) { - chatModel.chatId.value = null - ModalManager.end.closeModals() - } - ntfManager.cancelNotificationsForChat(chatInfo.id) - close?.invoke() + val text = "${groupInfo.displayName}\n\n${generalGetString(messageId)}" + val deleteGroup: (LinkNotice?) -> Unit = { notice -> + withBGApi { + val r = chatModel.controller.apiDeleteChat(chat.remoteHostId, chatInfo.chatType, chatInfo.apiId, notice = notice) + if (r) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(chat.remoteHostId, chatInfo.id) + if (chatModel.chatId.value == chatInfo.id) { + chatModel.chatId.value = null + ModalManager.end.closeModals() } + ntfManager.cancelNotificationsForChat(chatInfo.id) + close?.invoke() } } - }, - destructive = true, - ) + } + } + if (canBanOnChatDeletion(groupInfo)) { + showLinkBanAlert( + generalGetString(titleId), + text, + generalGetString(MR.strings.link_ban_from_connecting), + LinkBan.No, + listOf(LinkBanAction(generalGetString(MR.strings.delete_verb), deleteGroup)) + ) + } else { + AlertManager.shared.showAlertDialog( + title = generalGetString(titleId), + text = text, + parseHtml = false, + confirmText = generalGetString(MR.strings.delete_verb), + onConfirm = { deleteGroup(null) }, + destructive = true, + ) + } } fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { @@ -297,61 +309,25 @@ fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMember) { } } }) - } else if (groupInfo.useRelays) { - AlertManager.shared.showAlertDialogButtonsColumn( - generalGetString(MR.strings.button_remove_subscriber_question), - generalGetString(MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone), - buttons = { - Column { - SectionItemView({ - AlertManager.shared.hideAlert() - removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false) - }) { - Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) - } - SectionItemView({ - AlertManager.shared.hideAlert() - removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true) - }) { - Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) - } - SectionItemView({ - AlertManager.shared.hideAlert() - }) { - Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - } - }) } else { - val titleId = MR.strings.button_remove_member_question - val messageId = if (groupInfo.businessChat == null) - MR.strings.member_will_be_removed_from_group_cannot_be_undone - else - MR.strings.member_will_be_removed_from_chat_cannot_be_undone - AlertManager.shared.showAlertDialogButtonsColumn( - generalGetString(titleId), - generalGetString(messageId), - buttons = { - Column { - SectionItemView({ - AlertManager.shared.hideAlert() - removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false) - }) { - Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) - } - SectionItemView({ - AlertManager.shared.hideAlert() - removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true) - }) { - Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) - } - SectionItemView({ - AlertManager.shared.hideAlert() - }) { - Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } + showMemberBanAlert( + groupInfo, + showBan = mem.memberStatus != GroupMemberStatus.MemInvited, + title = generalGetString(if (groupInfo.useRelays) MR.strings.button_remove_subscriber_question else MR.strings.button_remove_member_question), + text = generalGetString( + if (groupInfo.useRelays) MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone + else if (groupInfo.businessChat == null) MR.strings.member_will_be_removed_from_group_cannot_be_undone + else MR.strings.member_will_be_removed_from_chat_cannot_be_undone + ), + actions = listOf( + LinkBanAction(generalGetString(MR.strings.remove_member_confirmation)) { notice -> + removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false, notice = notice) + }, + LinkBanAction(generalGetString(MR.strings.remove_member_delete_messages_confirmation)) { notice -> + removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true, notice = notice) } - }) + ) + ) } } @@ -360,22 +336,75 @@ private fun removeMembersAlert(rhId: Long?, groupInfo: GroupInfo, memberIds: Lis MR.strings.members_will_be_removed_from_group_cannot_be_undone else MR.strings.members_will_be_removed_from_chat_cannot_be_undone + showMemberBanAlert( + groupInfo, + showBan = true, + title = generalGetString(MR.strings.button_remove_members_question), + text = generalGetString(messageId), + actions = listOf( + LinkBanAction(generalGetString(MR.strings.remove_member_confirmation)) { notice -> + removeMembers(rhId, groupInfo, memberIds, withMessages = false, notice = notice, onSuccess = onSuccess) + }, + LinkBanAction(generalGetString(MR.strings.remove_member_delete_messages_confirmation)) { notice -> + removeMembers(rhId, groupInfo, memberIds, withMessages = true, notice = notice, onSuccess = onSuccess) + } + ) + ) +} + +enum class LinkBan { + No, Hour, Day, Week, Month, Permanent; + + val text: String get() = generalGetString( + when (this) { + No -> MR.strings.link_ban_no + Hour -> MR.strings.link_ban_1_hour + Day -> MR.strings.link_ban_1_day + Week -> MR.strings.link_ban_1_week + Month -> MR.strings.link_ban_1_month + Permanent -> MR.strings.link_ban_permanently + } + ) + + fun notice(reason: ReportReason): LinkNotice? = when (this) { + No -> null + Hour -> LinkNotice(ttl = 3600, reason = reason) + Day -> LinkNotice(ttl = 86400, reason = reason) + Week -> LinkNotice(ttl = 604800, reason = reason) + Month -> LinkNotice(ttl = 2592000, reason = reason) + Permanent -> LinkNotice(ttl = null, reason = reason) + } +} + +class LinkBanAction(val title: String, val action: (LinkNotice?) -> Unit) + +@Composable +fun LinkBanRows(label: String, ban: MutableState, reason: MutableState) { + ExposedDropDownSettingRow(label, LinkBan.entries.map { it to it.text }, ban, onSelected = { ban.value = it }) + if (ban.value != LinkBan.No) { + ExposedDropDownSettingRow(generalGetString(MR.strings.link_ban_reason), ReportReason.supportedReasons.map { it to it.text }, reason, onSelected = { reason.value = it }) + } +} + +fun showLinkBanAlert(title: String, text: String?, banLabel: String?, ban: LinkBan, actions: List) { + val banState = mutableStateOf(ban) + val reason = mutableStateOf(ReportReason.Spam) AlertManager.shared.showAlertDialogButtonsColumn( - generalGetString(MR.strings.button_remove_members_question), - generalGetString(messageId), + title, + text, + parseHtml = false, buttons = { Column { - SectionItemView({ - AlertManager.shared.hideAlert() - removeMembers(rhId, groupInfo, memberIds, withMessages = false, onSuccess = onSuccess) - }) { - Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + if (banLabel != null) { + LinkBanRows(banLabel, banState, reason) } - SectionItemView({ - AlertManager.shared.hideAlert() - removeMembers(rhId, groupInfo, memberIds, withMessages = true, onSuccess = onSuccess) - }) { - Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + actions.forEach { a -> + SectionItemView({ + AlertManager.shared.hideAlert() + a.action(banState.value.notice(reason.value)) + }) { + Text(a.title, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } } SectionItemView({ AlertManager.shared.hideAlert() @@ -386,6 +415,18 @@ private fun removeMembersAlert(rhId: Long?, groupInfo: GroupInfo, memberIds: Lis }) } +fun showMemberBanAlert(groupInfo: GroupInfo, showBan: Boolean, title: String, text: String?, actions: List) { + if (showBan) { + val banLabel = generalGetString(if (groupInfo.businessChat == null) MR.strings.link_ban_from_joining else MR.strings.link_ban_from_connecting) + showLinkBanAlert(title, text, banLabel, LinkBan.Day, actions) + } else { + showLinkBanAlert(title, text, null, LinkBan.No, actions) + } +} + +fun canBanOnChatDeletion(groupInfo: GroupInfo): Boolean = + groupInfo.businessChat?.chatType == BusinessChatType.Customer && groupInfo.membership.memberCurrent + @Composable fun SearchButton( modifier: Modifier, @@ -1397,9 +1438,9 @@ private fun setGroupAlias(chat: Chat, localAlias: String, chatModel: ChatModel) } } -fun removeMembers(rhId: Long?, groupInfo: GroupInfo, memberIds: List, withMessages: Boolean, onSuccess: () -> Unit = {}) { +fun removeMembers(rhId: Long?, groupInfo: GroupInfo, memberIds: List, withMessages: Boolean, notice: LinkNotice? = null, onSuccess: () -> Unit = {}) { withBGApi { - val r = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, memberIds, withMessages = withMessages) + val r = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, memberIds, withMessages = withMessages, notice = notice) if (r != null) { val (updatedGroupInfo, updatedMembers) = r withContext(Dispatchers.Main) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 3e5f64659b..d1ef467917 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -270,60 +270,25 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c } } }) - } else if (groupInfo.useRelays) { - AlertManager.shared.showAlertDialogButtonsColumn( - generalGetString(MR.strings.button_remove_subscriber_question), - generalGetString(MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone), - buttons = { - Column { - SectionItemView({ - AlertManager.shared.hideAlert() - removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close) - }) { - Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) - } - SectionItemView({ - AlertManager.shared.hideAlert() - removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close) - }) { - Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) - } - SectionItemView({ - AlertManager.shared.hideAlert() - }) { - Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - } - }) } else { - val messageId = if (groupInfo.businessChat == null) - MR.strings.member_will_be_removed_from_group_cannot_be_undone - else - MR.strings.member_will_be_removed_from_chat_cannot_be_undone - AlertManager.shared.showAlertDialogButtonsColumn( - generalGetString(MR.strings.button_remove_member_question), - generalGetString(messageId), - buttons = { - Column { - SectionItemView({ - AlertManager.shared.hideAlert() - removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close) - }) { - Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) - } - SectionItemView({ - AlertManager.shared.hideAlert() - removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close) - }) { - Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) - } - SectionItemView({ - AlertManager.shared.hideAlert() - }) { - Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } + showMemberBanAlert( + groupInfo, + showBan = member.memberStatus != GroupMemberStatus.MemInvited, + title = generalGetString(if (groupInfo.useRelays) MR.strings.button_remove_subscriber_question else MR.strings.button_remove_member_question), + text = generalGetString( + if (groupInfo.useRelays) MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone + else if (groupInfo.businessChat == null) MR.strings.member_will_be_removed_from_group_cannot_be_undone + else MR.strings.member_will_be_removed_from_chat_cannot_be_undone + ), + actions = listOf( + LinkBanAction(generalGetString(MR.strings.remove_member_confirmation)) { notice -> + removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close, notice) + }, + LinkBanAction(generalGetString(MR.strings.remove_member_delete_messages_confirmation)) { notice -> + removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close, notice) } - }) + ) + ) } } @@ -339,9 +304,9 @@ fun deleteMemberMessagesDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupM ) } -fun removeMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, withMessages: Boolean, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun removeMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, withMessages: Boolean, chatModel: ChatModel, close: (() -> Unit)? = null, notice: LinkNotice? = null) { withBGApi { - val r = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId), withMessages = withMessages) + val r = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId), withMessages = withMessages, notice = notice) if (r != null) { val (updatedGroupInfo, removedMembers) = r withContext(Dispatchers.Main) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 5e5769891a..18e10f95d0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -16,6 +16,7 @@ import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.* +import kotlinx.datetime.Instant enum class ConnectionLinkType { INVITATION, CONTACT, GROUP @@ -244,6 +245,11 @@ private suspend fun planAndConnectTask( cleanup() } } + is ContactAddressPlan.LinkNotice -> { + Log.d(TAG, "planAndConnect, .ContactAddress, .LinkNotice") + showLinkNoticeAlert(LinkNoticeTarget.Address, connectionPlan.contactAddressPlan.expiresAt, connectionPlan.contactAddressPlan.reason) + cleanup() + } } is ConnectionPlan.GroupLink -> when (connectionPlan.groupLinkPlan) { is GroupLinkPlan.Ok -> @@ -388,6 +394,12 @@ private suspend fun planAndConnectTask( cleanup() } } + is GroupLinkPlan.LinkNotice -> { + Log.d(TAG, "planAndConnect, .GroupLink, .LinkNotice") + val isChannel = (target as? ConnectTarget.Link)?.linkType == SimplexLinkType.channel || planSimplexName?.nameType == SimplexNameType.publicGroup + showLinkNoticeAlert(if (isChannel) LinkNoticeTarget.Channel else LinkNoticeTarget.Group, connectionPlan.groupLinkPlan.expiresAt, connectionPlan.groupLinkPlan.reason) + cleanup() + } } is ConnectionPlan.Error -> { Log.d(TAG, "planAndConnect, error ${connectionPlan.chatError}") @@ -436,6 +448,33 @@ suspend fun connectViaUri( return pcc != null } +enum class LinkNoticeTarget { + Group, Channel, Address, Link +} + +fun showLinkNoticeAlert(target: LinkNoticeTarget, expiresAt: Instant?, reason: ReportReason?) { + val join = target == LinkNoticeTarget.Group || target == LinkNoticeTarget.Channel + val banned = generalGetString( + when (target) { + LinkNoticeTarget.Group -> MR.strings.link_ban_alert_group + LinkNoticeTarget.Channel -> MR.strings.link_ban_alert_channel + LinkNoticeTarget.Address -> MR.strings.link_ban_alert_address + LinkNoticeTarget.Link -> MR.strings.link_ban_alert_link + } + ) + val until = + if (expiresAt != null) { + String.format(generalGetString(if (join) MR.strings.link_ban_alert_join_after else MR.strings.link_ban_alert_connect_after), localTimestamp(expiresAt)) + } else { + generalGetString(if (join) MR.strings.link_ban_alert_cant_join else MR.strings.link_ban_alert_cant_connect) + } + val reasonLine = if (reason == null || reason is ReportReason.Unknown) null else String.format(generalGetString(MR.strings.link_ban_alert_reason), reason.text) + AlertManager.privacySensitive.showAlertMsg( + generalGetString(MR.strings.link_ban_alert_title), + listOfNotNull(banned, until, reasonLine).joinToString("\n") + ) +} + fun planToConnectionLinkType(connectionPlan: ConnectionPlan): ConnectionLinkType? { return when(connectionPlan) { is ConnectionPlan.InvitationLink -> ConnectionLinkType.INVITATION diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index bce235edec..ddcddcd330 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2011,6 +2011,25 @@ Remove and delete messages Delete messages Remove member + Ban from joining + Ban from connecting + Reason + No + 1 hour + 1 day + 1 week + 1 month + Permanently + Banned + You were banned from this group. + You were banned from this channel. + You were banned from connecting via this address. + You were banned from connecting via this link. + You can join again after %s. + You can connect again after %s. + You can\'t join it again. + You can\'t connect again. + Reason: %s. Block member? Block member Block diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index f10cbf44fd..c4fee3156f 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -727,7 +727,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o rejectPendingMember rjctNotice = do let gmId = groupMemberId' m sendComposedMessages cc (SRGroup groupId (Just $ GCSMemberSupport (Just gmId)) False) [MCText rjctNotice] - sendChatCmd cc (APIRemoveMembers groupId [gmId] False) >>= \case + sendChatCmd cc (APIRemoveMembers groupId [gmId] False Nothing) >>= \case Right (CRUserDeletedMembers _ _ (_ : _) _ _) -> do atomically $ TM.delete gmId $ pendingCaptchas env logInfo $ "Member " <> viewName displayName <> " rejected, group " <> tshow groupId <> ":" <> viewGroupName g @@ -953,6 +953,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o GLPNoRelays _ -> sendMessage cc ct $ T.toTitle gt <> " has no active relays. Please try again later." GLPUpdateRequired _ -> sendMessage cc ct $ T.toTitle gt <> " requires a newer version." GLPOwnLink _ -> sendMessage cc ct "Unexpected error. Please report it to directory admins." + GLPLinkNotice {} -> sendMessage cc ct $ "Directory is banned from joining this " <> gt <> "." _ -> sendMessage cc ct "Unexpected error. Please report it to directory admins." joinAndRegisterPublicGroup :: Contact -> CreatedLinkContact -> MemberId -> Text -> GroupShortLinkData -> IO () diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index f90e8e08da..973312e15d 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -888,19 +888,20 @@ Remove members. Requires Admin role. - groupId: int64 - groupMemberIds: [int64] - withMessages: bool +- notice: [LinkNotice](./TYPES.md#linknotice)? **Syntax**: ``` -/_remove # [,...][ messages=on] +/_remove # [,...][ messages=on][ notice=] ``` ```javascript -'/_remove #' + groupId + ' ' + groupMemberIds.join(',') + (withMessages ? ' messages=on' : '') // JavaScript +'/_remove #' + groupId + ' ' + groupMemberIds.join(',') + (withMessages ? ' messages=on' : '') + (notice ? ' notice=' + JSON.stringify(notice) : '') // JavaScript ``` ```python -'/_remove #' + str(groupId) + ' ' + ','.join(map(str, groupMemberIds)) + (' messages=on' if withMessages else '') # Python +'/_remove #' + str(groupId) + ' ' + ','.join(map(str, groupMemberIds)) + (' messages=on' if withMessages else '') + ((' notice=' + json.dumps(notice)) if notice is not None else '') # Python ``` **Responses**: @@ -1857,19 +1858,20 @@ Delete chat. **Parameters**: - chatRef: [ChatRef](./TYPES.md#chatref) - chatDeleteMode: [ChatDeleteMode](./TYPES.md#chatdeletemode) +- notice: [LinkNotice](./TYPES.md#linknotice)? **Syntax**: ``` -/_delete +/_delete [ notice=] ``` ```javascript -'/_delete ' + ChatRef.cmdString(chatRef) + ' ' + ChatDeleteMode.cmdString(chatDeleteMode) // JavaScript +'/_delete ' + ChatRef.cmdString(chatRef) + ' ' + ChatDeleteMode.cmdString(chatDeleteMode) + (notice ? ' notice=' + JSON.stringify(notice) : '') // JavaScript ``` ```python -'/_delete ' + ChatRef_cmd_string(chatRef) + ' ' + ChatDeleteMode_cmd_string(chatDeleteMode) # Python +'/_delete ' + ChatRef_cmd_string(chatRef) + ' ' + ChatDeleteMode_cmd_string(chatDeleteMode) + ((' notice=' + json.dumps(notice)) if notice is not None else '') # Python ``` **Responses**: diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index f1122979a9..ca84196c8e 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -129,6 +129,7 @@ This file is generated automatically. - [InvitationLinkPlan](#invitationlinkplan) - [InvitedBy](#invitedby) - [LinkContent](#linkcontent) +- [LinkNotice](#linknotice) - [LinkOwnerSig](#linkownersig) - [LinkPreview](#linkpreview) - [LocalBadge](#localbadge) @@ -1240,6 +1241,11 @@ UnsupportedConnReq: ConnReqMessageProhibited: - type: "connReqMessageProhibited" +LinkNotice: +- type: "linkNotice" +- expiresAt: UTCTime? +- reason: [ReportReason](#reportreason)? + ContactNotReady: - type: "contactNotReady" - contact: [Contact](#contact) @@ -1939,6 +1945,11 @@ ContactViaAddress: - type: "contactViaAddress" - contact: [Contact](#contact) +LinkNotice: +- type: "linkNotice" +- expiresAt: UTCTime? +- reason: [ReportReason](#reportreason)? + --- @@ -2557,6 +2568,11 @@ UpdateRequired: - type: "updateRequired" - groupSLinkData_: [GroupShortLinkData](#groupshortlinkdata)? +LinkNotice: +- type: "linkNotice" +- expiresAt: UTCTime? +- reason: [ReportReason](#reportreason)? + --- @@ -2849,6 +2865,15 @@ Unknown: - json: JSONObject +--- + +## LinkNotice + +**Record type**: +- ttl: int64? +- reason: [ReportReason](#reportreason)? + + --- ## LinkOwnerSig diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 4ffba7bfbe..4bcf7d3131 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -115,7 +115,7 @@ chatCommandsDocsData = ("APIAcceptMember", [], "Accept group member. Requires Admin role.", ["CRMemberAccepted", "CRChatCmdError"], [TD "CEGroupMemberNotActive" "Member is not connected yet"], Just UNBackground, "/_accept member #" <> Param "groupId" <> " " <> Param "groupMemberId" <> " " <> Param "memberRole"), ("APIMembersRole", [], "Set members role. Requires Admin role.", ["CRMembersRoleUser", "CRChatCmdError"], [], Just UNBackground, "/_member role #" <> Param "groupId" <> " " <> Join ',' "groupMemberIds" <> " " <> Param "memberRole"), ("APIBlockMembersForAll", [], "Block members. Requires Moderator role.", ["CRMembersBlockedForAllUser", "CRChatCmdError"], [], Just UNBackground, "/_block #" <> Param "groupId" <> " " <> Join ',' "groupMemberIds" <> OnOffParam "blocked" "blocked" Nothing), - ("APIRemoveMembers", [], "Remove members. Requires Admin role.", ["CRUserDeletedMembers", "CRChatCmdError"], ["CEGroupMemberNotFound"], Just UNBackground, "/_remove #" <> Param "groupId" <> " " <> Join ',' "groupMemberIds" <> OnOffParam "messages" "withMessages" (Just False)), + ("APIRemoveMembers", [], "Remove members. Requires Admin role.", ["CRUserDeletedMembers", "CRChatCmdError"], ["CEGroupMemberNotFound"], Just UNBackground, "/_remove #" <> Param "groupId" <> " " <> Join ',' "groupMemberIds" <> OnOffParam "messages" "withMessages" (Just False) <> Optional "" (" notice=" <> Json "$0") "notice"), ("APILeaveGroup", [], "Leave group.", ["CRLeftMemberUser", "CRChatCmdError"], [], Just UNBackground, "/_leave #" <> Param "groupId"), ("APIListMembers", [], "Get group members.", ["CRGroupMembers", "CRChatCmdError"], [], Nothing, "/_members #" <> Param "groupId"), ("APINewGroup", [], "Create group.", ["CRGroupCreated", "CRChatCmdError"], [], Nothing, "/_group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Json "groupProfile"), @@ -151,7 +151,7 @@ chatCommandsDocsData = [ ("APIListContacts", [], "Get contacts.", ["CRContactsList", "CRChatCmdError"], [], Nothing, "/_contacts " <> Param "userId"), ("APIListGroups", [], "Get groups.", ["CRGroupsList", "CRChatCmdError"], [], Nothing, "/_groups " <> Param "userId" <> Optional "" (" @" <> Param "$0") "contactId_" <> Optional "" (" " <> Param "$0") "search"), ("APIGetChats", [], "Get chat previews. Supports time-based pagination — use this instead of APIListContacts / APIListGroups when scanning at scale (those load every record into memory and fail on large databases).", ["CRApiChats", "CRChatCmdError"], [], Nothing, "/_get chats " <> Param "userId" <> OnOffParam "pcc" "pendingConnections" (Just False) <> Optional "" (" " <> Param "$0") "pagination" <> " " <> Json "query"), - ("APIDeleteChat", [], "Delete chat.", ["CRContactDeleted", "CRContactConnectionDeleted", "CRGroupDeletedUser", "CRChatCmdError"], [], Just UNBackground, "/_delete " <> Param "chatRef" <> " " <> Param "chatDeleteMode"), + ("APIDeleteChat", [], "Delete chat.", ["CRContactDeleted", "CRContactConnectionDeleted", "CRGroupDeletedUser", "CRChatCmdError"], [], Just UNBackground, "/_delete " <> Param "chatRef" <> " " <> Param "chatDeleteMode" <> Optional "" (" notice=" <> Json "$0") "notice"), ("APISetGroupCustomData", [], "Set group custom data.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set custom #" <> Param "groupId" <> Optional "" (" " <> Json "$0") "customData"), ("APISetContactCustomData", [], "Set contact custom data.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set custom @" <> Param "contactId" <> Optional "" (" " <> Json "$0") "customData"), ("APISetUserAutoAcceptMemberContacts", [], "Set auto-accept member contacts.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set accept member contacts " <> Param "userId" <> " " <> OnOff "onOff"), diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index ac5992770e..aee1567f1a 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -319,6 +319,7 @@ chatTypesDocsData = (sti @InvitationLinkPlan, STUnion, "ILP", [], "", ""), (sti @InvitedBy, STUnion, "IB", [], "", ""), (sti @LinkContent, STUnion, "LC", [], "", ""), + (sti @LinkNotice, STRecord, "", [], "", ""), (sti @LinkOwnerSig, STRecord, "", [], "", ""), (sti @LinkPreview, STRecord, "", [], "", ""), (sti @BadgeInfo, STRecord, "", [], "", ""), @@ -559,6 +560,7 @@ deriving instance Generic JSONCIDirection deriving instance Generic JSONCIFileStatus deriving instance Generic JSONCIStatus deriving instance Generic LinkContent +deriving instance Generic LinkNotice deriving instance Generic LinkOwnerSig deriving instance Generic LinkPreview deriving instance Generic BadgeInfo diff --git a/docs/protocol/simplex-chat.md b/docs/protocol/simplex-chat.md index 040064864c..180d57d79a 100644 --- a/docs/protocol/simplex-chat.md +++ b/docs/protocol/simplex-chat.md @@ -254,11 +254,11 @@ Currently members can have one of four roles - `owner`, `admin`, `member` and `o `x.grp.mem.con` message is sent by members connecting inside group to inviting member, to notify the inviting member they have completed the connection and no longer require forwarding messages between them. -`x.grp.mem.del` message is sent to delete a member - it is sent to all members by the member who deletes the member referenced in this message. This message MUST only be sent by members with `admin` or `owner` role. Receiving clients MUST ignore this message if it is received from member with `member` role. +`x.grp.mem.del` message is sent to delete a member - it is sent to all members by the member who deletes the member referenced in this message. This message MUST only be sent by members with `admin` or `owner` role. Receiving clients MUST ignore this message if it is received from member with `member` role. The client of the deleted member applies optional `notice` property to the group link (see [Link notices](#link-notices)). `x.grp.leave` message is sent to all members by the member leaving the group. If the only group `owner` leaves the group, it will not be possible to delete it with `x.grp.del` message - but all members can still leave the group with `x.grp.leave` message and then delete a local copy of the group. -`x.grp.del` message is sent to all members by the member who deletes the group. Clients who received this message SHOULD keep a local copy of the deleted group, until it is deleted by the user. This message MUST only be sent by members with `owner` role. Receiving clients MUST ignore this message if it is received from member other than with `owner` role. +`x.grp.del` message is sent to all members by the member who deletes the group. Clients who received this message SHOULD keep a local copy of the deleted group, until it is deleted by the user. This message MUST only be sent by members with `owner` role. Receiving clients MUST ignore this message if it is received from member other than with `owner` role. Receiving clients apply optional `notice` property to the business address of a deleted business chat (see [Link notices](#link-notices)). `x.grp.info` message is sent to all members by the member who updated group profile. Only group owners can update group profiles. Clients MAY implement some conflict resolution strategy - it is currently not implemented by SimpleX Chat client. This message MUST only be sent by members with `owner` role. Receiving clients MUST ignore this message if it is received from member other than with `owner` role. @@ -266,6 +266,12 @@ Currently members can have one of four roles - `owner`, `admin`, `member` and `o `x.grp.msg.forward` message is sent by inviting member to forward messages between introduced members, while they are connecting. +### Link notices + +`x.grp.mem.del`, `x.grp.del` and `x.direct.del` messages MAY include `notice` property with optional `ttl` (seconds) and optional `reason` (`spam`, `content`, `community`, `profile` or `other`). The receiving client stores the notice for the link it joined or connected through, as recorded in its own group or contact record, and does not connect via this link until the notice expires. The notice expires `ttl` seconds after the server timestamp of the message; a notice without `ttl` does not expire. A new notice for the same link replaces the previous one. Clients MUST ignore a malformed notice and process the message. + +When `x.direct.del` message has `"silent": true` property, the receiving client stores the notice and does not delete the contact. Clients send it when the contact is deleted without notification, only to contacts with chat protocol version 21 or later. + ### Channels: relay-mediated groups Channels are groups where message delivery is mediated by dedicated relay members rather than by direct connections between all members. Channels extend the group sub-protocol with additional roles (`relay`, `observer`), message signing for administrative actions, a binary batch format for signed and forwarded messages, and an asynchronous delivery pipeline. diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index c217ef944c..dffb45af3b 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -319,13 +319,14 @@ export interface APIRemoveMembers { groupId: number // int64 groupMemberIds: number[] // int64, non-empty withMessages: boolean + notice?: T.LinkNotice } export namespace APIRemoveMembers { export type Response = CR.UserDeletedMembers | CR.ChatCmdError export function cmdString(self: APIRemoveMembers): string { - return '/_remove #' + self.groupId + ' ' + self.groupMemberIds.join(',') + (self.withMessages ? ' messages=on' : '') + return '/_remove #' + self.groupId + ' ' + self.groupMemberIds.join(',') + (self.withMessages ? ' messages=on' : '') + (self.notice ? ' notice=' + JSON.stringify(self.notice) : '') } } @@ -681,13 +682,14 @@ export namespace APIGetChats { export interface APIDeleteChat { chatRef: T.ChatRef chatDeleteMode: T.ChatDeleteMode + notice?: T.LinkNotice } export namespace APIDeleteChat { export type Response = CR.ContactDeleted | CR.ContactConnectionDeleted | CR.GroupDeletedUser | CR.ChatCmdError export function cmdString(self: APIDeleteChat): string { - return '/_delete ' + T.ChatRef.cmdString(self.chatRef) + ' ' + T.ChatDeleteMode.cmdString(self.chatDeleteMode) + return '/_delete ' + T.ChatRef.cmdString(self.chatRef) + ' ' + T.ChatDeleteMode.cmdString(self.chatDeleteMode) + (self.notice ? ' notice=' + JSON.stringify(self.notice) : '') } } diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index d7db7f259f..c53b115d48 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -1183,6 +1183,7 @@ export type ChatErrorType = | ChatErrorType.NotResolvedLocally | ChatErrorType.UnsupportedConnReq | ChatErrorType.ConnReqMessageProhibited + | ChatErrorType.LinkNotice | ChatErrorType.ContactNotReady | ChatErrorType.ContactNotActive | ChatErrorType.ContactDisabled @@ -1263,6 +1264,7 @@ export namespace ChatErrorType { | "notResolvedLocally" | "unsupportedConnReq" | "connReqMessageProhibited" + | "linkNotice" | "contactNotReady" | "contactNotActive" | "contactDisabled" @@ -1434,6 +1436,12 @@ export namespace ChatErrorType { type: "connReqMessageProhibited" } + export interface LinkNotice extends Interface { + type: "linkNotice" + expiresAt?: string // ISO-8601 timestamp + reason?: ReportReason + } + export interface ContactNotReady extends Interface { type: "contactNotReady" contact: Contact @@ -2183,6 +2191,7 @@ export type ContactAddressPlan = | ContactAddressPlan.ConnectingProhibit | ContactAddressPlan.Known | ContactAddressPlan.ContactViaAddress + | ContactAddressPlan.LinkNotice export namespace ContactAddressPlan { export type Tag = @@ -2192,6 +2201,7 @@ export namespace ContactAddressPlan { | "connectingProhibit" | "known" | "contactViaAddress" + | "linkNotice" interface Interface { type: Tag @@ -2225,6 +2235,12 @@ export namespace ContactAddressPlan { type: "contactViaAddress" contact: Contact } + + export interface LinkNotice extends Interface { + type: "linkNotice" + expiresAt?: string // ISO-8601 timestamp + reason?: ReportReason + } } export interface ContactShortLinkData { @@ -2821,6 +2837,7 @@ export type GroupLinkPlan = | GroupLinkPlan.Known | GroupLinkPlan.NoRelays | GroupLinkPlan.UpdateRequired + | GroupLinkPlan.LinkNotice export namespace GroupLinkPlan { export type Tag = @@ -2831,6 +2848,7 @@ export namespace GroupLinkPlan { | "known" | "noRelays" | "updateRequired" + | "linkNotice" interface Interface { type: Tag @@ -2874,6 +2892,12 @@ export namespace GroupLinkPlan { type: "updateRequired" groupSLinkData_?: GroupShortLinkData } + + export interface LinkNotice extends Interface { + type: "linkNotice" + expiresAt?: string // ISO-8601 timestamp + reason?: ReportReason + } } export interface GroupMember { @@ -3119,6 +3143,11 @@ export namespace LinkContent { } } +export interface LinkNotice { + ttl?: number // int64 + reason?: ReportReason +} + export interface LinkOwnerSig { ownerId?: string chatBinding: string diff --git a/packages/simplex-chat-client/typescript/src/client.ts b/packages/simplex-chat-client/typescript/src/client.ts index 10c4ee0a81..dd07742619 100644 --- a/packages/simplex-chat-client/typescript/src/client.ts +++ b/packages/simplex-chat-client/typescript/src/client.ts @@ -176,8 +176,8 @@ export class ChatClient { } } - async apiDeleteChat(chatType: T.ChatType, chatId: number, deleteMode: T.ChatDeleteMode = {type: "full", notify: true}): Promise { - const r = await this.sendChatCmd(CC.APIDeleteChat.cmdString({chatRef: {chatType, chatId}, chatDeleteMode: deleteMode})) + async apiDeleteChat(chatType: T.ChatType, chatId: number, deleteMode: T.ChatDeleteMode = {type: "full", notify: true}, notice?: T.LinkNotice): Promise { + const r = await this.sendChatCmd(CC.APIDeleteChat.cmdString({chatRef: {chatType, chatId}, chatDeleteMode: deleteMode, notice})) switch (chatType) { case T.ChatType.Direct: if (r.type === "contactDeleted") return @@ -267,8 +267,8 @@ export class ChatClient { throw new ChatCommandError("error joining group", r) } - async apiRemoveMembers(groupId: number, memberIds: number[], withMessages = false): Promise { - const r = await this.sendChatCmd(CC.APIRemoveMembers.cmdString({groupId, groupMemberIds: memberIds, withMessages})) + async apiRemoveMembers(groupId: number, memberIds: number[], withMessages = false, notice?: T.LinkNotice): Promise { + const r = await this.sendChatCmd(CC.APIRemoveMembers.cmdString({groupId, groupMemberIds: memberIds, withMessages, notice})) if (r.type === "userDeletedMembers") return r.members throw new ChatCommandError("error removing member", r) } diff --git a/packages/simplex-chat-nodejs/src/api.ts b/packages/simplex-chat-nodejs/src/api.ts index 8c11e9fde3..3bb3365305 100644 --- a/packages/simplex-chat-nodejs/src/api.ts +++ b/packages/simplex-chat-nodejs/src/api.ts @@ -597,8 +597,8 @@ export class ChatApi { * Remove members. Requires Admin role. * Network usage: background. */ - async apiRemoveMembers(groupId: number, memberIds: number[], withMessages = false): Promise { - const r = await this.sendChatCmd(CC.APIRemoveMembers.cmdString({groupId, groupMemberIds: memberIds, withMessages})) + async apiRemoveMembers(groupId: number, memberIds: number[], withMessages = false, notice?: T.LinkNotice): Promise { + const r = await this.sendChatCmd(CC.APIRemoveMembers.cmdString({groupId, groupMemberIds: memberIds, withMessages, notice})) if (r.type === "userDeletedMembers") return r.members throw new ChatCommandError("error removing member", r) } @@ -806,8 +806,8 @@ export class ChatApi { * Delete chat. * Network usage: background. */ - async apiDeleteChat(chatType: T.ChatType, chatId: number, deleteMode: T.ChatDeleteMode = {type: "full", notify: true}): Promise { - const r = await this.sendChatCmd(CC.APIDeleteChat.cmdString({chatRef: {chatType, chatId}, chatDeleteMode: deleteMode})) + async apiDeleteChat(chatType: T.ChatType, chatId: number, deleteMode: T.ChatDeleteMode = {type: "full", notify: true}, notice?: T.LinkNotice): Promise { + const r = await this.sendChatCmd(CC.APIDeleteChat.cmdString({chatRef: {chatType, chatId}, chatDeleteMode: deleteMode, notice})) switch (chatType) { case T.ChatType.Direct: if (r.type === "contactDeleted") return diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py index e6155c9ce7..5312505fa9 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py @@ -282,10 +282,11 @@ class APIRemoveMembers(TypedDict): groupId: int # int64 groupMemberIds: list[int] # int64, non-empty withMessages: bool + notice: NotRequired["T.LinkNotice"] def APIRemoveMembers_cmd_string(self: APIRemoveMembers) -> str: - return '/_remove #' + str(self['groupId']) + ' ' + ','.join(map(str, self['groupMemberIds'])) + (' messages=on' if self['withMessages'] else '') + return '/_remove #' + str(self['groupId']) + ' ' + ','.join(map(str, self['groupMemberIds'])) + (' messages=on' if self['withMessages'] else '') + ((' notice=' + json.dumps(self.get('notice'))) if self.get('notice') is not None else '') APIRemoveMembers_Response = CR.UserDeletedMembers | CR.ChatCmdError @@ -599,10 +600,11 @@ APIGetChats_Response = CR.ApiChats | CR.ChatCmdError class APIDeleteChat(TypedDict): chatRef: "T.ChatRef" chatDeleteMode: "T.ChatDeleteMode" + notice: NotRequired["T.LinkNotice"] def APIDeleteChat_cmd_string(self: APIDeleteChat) -> str: - return '/_delete ' + T.ChatRef_cmd_string(self['chatRef']) + ' ' + T.ChatDeleteMode_cmd_string(self['chatDeleteMode']) + return '/_delete ' + T.ChatRef_cmd_string(self['chatRef']) + ' ' + T.ChatDeleteMode_cmd_string(self['chatDeleteMode']) + ((' notice=' + json.dumps(self.get('notice'))) if self.get('notice') is not None else '') APIDeleteChat_Response = CR.ContactDeleted | CR.ContactConnectionDeleted | CR.GroupDeletedUser | CR.ChatCmdError diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index ab06a24f53..05c45799e1 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -892,6 +892,11 @@ class ChatErrorType_unsupportedConnReq(TypedDict): class ChatErrorType_connReqMessageProhibited(TypedDict): type: Literal["connReqMessageProhibited"] +class ChatErrorType_linkNotice(TypedDict): + type: Literal["linkNotice"] + expiresAt: NotRequired[str] # ISO-8601 timestamp + reason: NotRequired["ReportReason"] + class ChatErrorType_contactNotReady(TypedDict): type: Literal["contactNotReady"] contact: "Contact" @@ -1124,6 +1129,7 @@ ChatErrorType = ( | ChatErrorType_notResolvedLocally | ChatErrorType_unsupportedConnReq | ChatErrorType_connReqMessageProhibited + | ChatErrorType_linkNotice | ChatErrorType_contactNotReady | ChatErrorType_contactNotActive | ChatErrorType_contactDisabled @@ -1179,7 +1185,7 @@ ChatErrorType = ( | ChatErrorType_exception ) -ChatErrorType_Tag = Literal["noActiveUser", "noConnectionUser", "noSndFileUser", "noRcvFileUser", "userUnknown", "userExists", "chatRelayExists", "differentActiveUser", "cantDeleteActiveUser", "cantDeleteLastUser", "cantHideLastUser", "hiddenUserAlwaysMuted", "emptyUserPassword", "userAlreadyHidden", "userNotHidden", "invalidDisplayName", "chatNotStarted", "chatNotStopped", "chatStoreChanged", "invalidConnReq", "simplexDomainNotReady", "notResolvedLocally", "unsupportedConnReq", "connReqMessageProhibited", "contactNotReady", "contactNotActive", "contactDisabled", "connectionDisabled", "groupUserRole", "groupMemberInitialRole", "contactIncognitoCantInvite", "groupIncognitoCantInvite", "groupContactRole", "groupDuplicateMember", "groupDuplicateMemberId", "groupNotJoined", "groupMemberNotActive", "cantBlockMemberForSelf", "groupMemberUserRemoved", "groupMemberNotFound", "groupCantResendInvitation", "groupInternal", "fileNotFound", "fileSize", "fileAlreadyReceiving", "fileCancelled", "fileCancel", "fileAlreadyExists", "fileWrite", "fileSend", "fileRcvChunk", "fileInternal", "fileImageType", "fileImageSize", "fileNotReceived", "fileNotApproved", "fallbackToSMPProhibited", "inlineFileProhibited", "invalidForward", "invalidChatItemUpdate", "invalidChatItemDelete", "hasCurrentCall", "noCurrentCall", "callContact", "directMessagesProhibited", "agentVersion", "agentNoSubResult", "commandError", "badgeRedeemError", "agentCommandError", "invalidFileDescription", "connectionIncognitoChangeProhibited", "connectionUserChangeProhibited", "peerChatVRangeIncompatible", "relayTestError", "internalError", "exception"] +ChatErrorType_Tag = Literal["noActiveUser", "noConnectionUser", "noSndFileUser", "noRcvFileUser", "userUnknown", "userExists", "chatRelayExists", "differentActiveUser", "cantDeleteActiveUser", "cantDeleteLastUser", "cantHideLastUser", "hiddenUserAlwaysMuted", "emptyUserPassword", "userAlreadyHidden", "userNotHidden", "invalidDisplayName", "chatNotStarted", "chatNotStopped", "chatStoreChanged", "invalidConnReq", "simplexDomainNotReady", "notResolvedLocally", "unsupportedConnReq", "connReqMessageProhibited", "linkNotice", "contactNotReady", "contactNotActive", "contactDisabled", "connectionDisabled", "groupUserRole", "groupMemberInitialRole", "contactIncognitoCantInvite", "groupIncognitoCantInvite", "groupContactRole", "groupDuplicateMember", "groupDuplicateMemberId", "groupNotJoined", "groupMemberNotActive", "cantBlockMemberForSelf", "groupMemberUserRemoved", "groupMemberNotFound", "groupCantResendInvitation", "groupInternal", "fileNotFound", "fileSize", "fileAlreadyReceiving", "fileCancelled", "fileCancel", "fileAlreadyExists", "fileWrite", "fileSend", "fileRcvChunk", "fileInternal", "fileImageType", "fileImageSize", "fileNotReceived", "fileNotApproved", "fallbackToSMPProhibited", "inlineFileProhibited", "invalidForward", "invalidChatItemUpdate", "invalidChatItemDelete", "hasCurrentCall", "noCurrentCall", "callContact", "directMessagesProhibited", "agentVersion", "agentNoSubResult", "commandError", "badgeRedeemError", "agentCommandError", "invalidFileDescription", "connectionIncognitoChangeProhibited", "connectionUserChangeProhibited", "peerChatVRangeIncompatible", "relayTestError", "internalError", "exception"] ChatFeature = Literal["timedMessages", "fullDelete", "reactions", "voice", "files", "calls", "sessions"] @@ -1547,6 +1553,11 @@ class ContactAddressPlan_contactViaAddress(TypedDict): type: Literal["contactViaAddress"] contact: "Contact" +class ContactAddressPlan_linkNotice(TypedDict): + type: Literal["linkNotice"] + expiresAt: NotRequired[str] # ISO-8601 timestamp + reason: NotRequired["ReportReason"] + ContactAddressPlan = ( ContactAddressPlan_ok | ContactAddressPlan_ownLink @@ -1554,9 +1565,10 @@ ContactAddressPlan = ( | ContactAddressPlan_connectingProhibit | ContactAddressPlan_known | ContactAddressPlan_contactViaAddress + | ContactAddressPlan_linkNotice ) -ContactAddressPlan_Tag = Literal["ok", "ownLink", "connectingConfirmReconnect", "connectingProhibit", "known", "contactViaAddress"] +ContactAddressPlan_Tag = Literal["ok", "ownLink", "connectingConfirmReconnect", "connectingProhibit", "known", "contactViaAddress", "linkNotice"] class ContactShortLinkData(TypedDict): profile: "Profile" @@ -2001,6 +2013,11 @@ class GroupLinkPlan_updateRequired(TypedDict): type: Literal["updateRequired"] groupSLinkData_: NotRequired["GroupShortLinkData"] +class GroupLinkPlan_linkNotice(TypedDict): + type: Literal["linkNotice"] + expiresAt: NotRequired[str] # ISO-8601 timestamp + reason: NotRequired["ReportReason"] + GroupLinkPlan = ( GroupLinkPlan_ok | GroupLinkPlan_ownLink @@ -2009,9 +2026,10 @@ GroupLinkPlan = ( | GroupLinkPlan_known | GroupLinkPlan_noRelays | GroupLinkPlan_updateRequired + | GroupLinkPlan_linkNotice ) -GroupLinkPlan_Tag = Literal["ok", "ownLink", "connectingConfirmReconnect", "connectingProhibit", "known", "noRelays", "updateRequired"] +GroupLinkPlan_Tag = Literal["ok", "ownLink", "connectingConfirmReconnect", "connectingProhibit", "known", "noRelays", "updateRequired", "linkNotice"] class GroupMember(TypedDict): groupMemberId: int # int64 @@ -2175,6 +2193,10 @@ LinkContent = LinkContent_page | LinkContent_image | LinkContent_video | LinkCon LinkContent_Tag = Literal["page", "image", "video", "unknown"] +class LinkNotice(TypedDict): + ttl: NotRequired[int] # int64 + reason: NotRequired["ReportReason"] + class LinkOwnerSig(TypedDict): ownerId: NotRequired[str] chatBinding: str diff --git a/simplex-chat.cabal b/simplex-chat.cabal index e759fbdd3e..acb5a121ac 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -168,6 +168,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json + Simplex.Chat.Store.Postgres.Migrations.M20260926_link_notices else exposed-modules: Simplex.Chat.Archive @@ -345,6 +346,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json + Simplex.Chat.Store.SQLite.Migrations.M20260926_link_notices other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 9cc80fc54b..1342a8ff4b 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -430,7 +430,7 @@ data ChatCommand | APIChatRead {chatRef :: ChatRef} | APIChatItemsRead {chatRef :: ChatRef, chatItemIds :: NonEmpty ChatItemId} | APIChatUnread {chatRef :: ChatRef, unreadChat :: Bool} - | APIDeleteChat {chatRef :: ChatRef, chatDeleteMode :: ChatDeleteMode} -- currently delete mode settings are only applied to direct chats + | APIDeleteChat {chatRef :: ChatRef, chatDeleteMode :: ChatDeleteMode, notice :: Maybe LinkNotice} -- currently delete mode settings are only applied to direct chats | APIClearChat {chatRef :: ChatRef} | APIAcceptContact {incognito :: IncognitoEnabled, contactReqId :: Int64} | APIRejectContact {contactReqId :: Int64, notify :: Bool} @@ -468,7 +468,7 @@ data ChatCommand | APIDeleteMemberSupportChat GroupId GroupMemberId | APIMembersRole {groupId :: GroupId, groupMemberIds :: NonEmpty GroupMemberId, memberRole :: GroupMemberRole} | APIBlockMembersForAll {groupId :: GroupId, groupMemberIds :: NonEmpty GroupMemberId, blocked :: Bool} - | APIRemoveMembers {groupId :: GroupId, groupMemberIds :: NonEmpty GroupMemberId, withMessages :: Bool} + | APIRemoveMembers {groupId :: GroupId, groupMemberIds :: NonEmpty GroupMemberId, withMessages :: Bool, notice :: Maybe LinkNotice} | APILeaveGroup {groupId :: GroupId} | APIListMembers {groupId :: GroupId} | APIUpdateGroupProfile {groupId :: GroupId, groupProfile :: GroupProfile} @@ -617,7 +617,7 @@ data ChatCommand | AcceptMember GroupName ContactName GroupMemberRole | MemberRole GroupName ContactName GroupMemberRole | BlockForAll GroupName ContactName Bool - | RemoveMembers {groupName :: GroupName, members :: NonEmpty ContactName, withMessages :: Bool} + | RemoveMembers {groupName :: GroupName, members :: NonEmpty ContactName, withMessages :: Bool, notice :: Maybe LinkNotice} | LeaveGroup GroupName | AllowRelayGroup GroupName | DeleteGroup GroupName @@ -1170,6 +1170,7 @@ data ContactAddressPlan | CAPConnectingProhibit {contact :: Contact} | CAPKnown {contact :: Contact} | CAPContactViaAddress {contact :: Contact} + | CAPLinkNotice {expiresAt :: Maybe UTCTime, reason :: Maybe ReportReason} deriving (Show) data GroupLinkPlan @@ -1180,6 +1181,7 @@ data GroupLinkPlan | GLPKnown {groupInfo :: GroupInfo, groupUpdated :: Bool, ownerVerification :: Maybe OwnerVerification, linkOwners :: ListDef GroupLinkOwner} | GLPNoRelays {groupSLinkData_ :: Maybe GroupShortLinkData} | GLPUpdateRequired {groupSLinkData_ :: Maybe GroupShortLinkData} + | GLPLinkNotice {expiresAt :: Maybe UTCTime, reason :: Maybe ReportReason} deriving (Show) data GroupLinkOwner = GroupLinkOwner @@ -1515,6 +1517,7 @@ data ChatErrorType | CEUnsupportedConnReq | CEInvalidChatMessage {connection :: Connection, msgMeta :: Maybe MsgMetaJSON, messageData :: Text, message :: String} | CEConnReqMessageProhibited + | CELinkNotice {expiresAt :: Maybe UTCTime, reason :: Maybe ReportReason} | CEContactNotFound {contactName :: ContactName, suspectedMember :: Maybe (GroupInfo, GroupMember)} | CEContactNotReady {contact :: Contact} | CEContactNotActive {contact :: Contact} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index d02a189b1d..5cacf1b8bb 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1338,7 +1338,7 @@ processChatCommand cxt nm = \case liftIO $ updateNoteFolderUnreadChat db user nf unreadChat ok user _ -> throwCmdError "not supported" - APIDeleteChat cRef@(ChatRef cType chatId scope) cdm -> withUser $ \user@User {userId} -> case cType of + APIDeleteChat cRef@(ChatRef cType chatId scope) cdm notice_ -> withUser $ \user@User {userId} -> case cType of CTDirect -> do ct <- withFastStore $ \db -> getContact db cxt user chatId filesInfo <- withFastStore' $ \db -> getContactFileInfo db user ct @@ -1363,16 +1363,22 @@ processChatCommand cxt nm = \case getContact db cxt user chatId pure $ CRContactDeleted user ct' CDMMessages -> do + when (isJust notice_) $ throwCmdError "notice requires deleting the contact" void $ processChatCommand cxt nm $ APIClearChat cRef withFastStore' $ \db -> setContactChatDeleted db user ct True pure $ CRContactDeleted user ct {chatDeleted = True} where sendDelDeleteConns ct notify = do - let doSendDel = contactReady ct && contactActive ct && notify - when doSendDel $ void (sendDirectContactMessage user ct XDirectDel) `catchAllErrors` const (pure ()) + let delMsg_ + | not (contactReady ct && contactActive ct) = Nothing + | notify = Just $ XDirectDel False notice_ + | isJust notice_ && any (\Connection {peerChatVRange} -> maxVersion peerChatVRange >= linkNoticeVersion) (contactConn ct) = Just $ XDirectDel True notice_ + | otherwise = Nothing + forM_ delMsg_ $ \msg -> void (sendDirectContactMessage user ct msg) `catchAllErrors` const (pure ()) contactConnIds <- map aConnId <$> withFastStore' (\db -> getContactConnections db cxt userId ct) - deleteAgentConnectionsAsync' contactConnIds doSendDel + deleteAgentConnectionsAsync' contactConnIds (isJust delMsg_) CTContactConnection -> withConnectionLock "deleteChat contactConnection" chatId $ do + when (isJust notice_) $ throwCmdError "notice is not supported for pending connections" conn@PendingContactConnection {pccAgentConnId = AgentConnId acId} <- withFastStore $ \db -> getPendingContactConnection db userId chatId deleteAgentConnectionAsync acId withFastStore' $ \db -> deletePendingContactConnection db userId chatId @@ -1391,7 +1397,7 @@ processChatCommand cxt nm = \case let doSendDel = memberActive membership && isOwner msgSigned <- if doSendDel - then (\SndMessage {signedMsg_} -> isJust signedMsg_) <$> sendGroupMessage' user g recipients XGrpDel + then (\SndMessage {signedMsg_} -> isJust signedMsg_) <$> sendGroupMessage' user g recipients (XGrpDel notice_) else pure False deleteGroupLinkIfExists user gInfo deleteMembersConnections' user members doSendDel @@ -2269,6 +2275,7 @@ processChatCommand cxt nm = \case toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct') ci] pure $ CRStartedConnectionToContact user ct' customUserProfile Just PreparedContact {connLinkToConnect = ACCL SCMContact ccLink, welcomeSharedMsgId, requestSharedMsgId} -> do + checkLinkNotice $ connFullLink ccLink msg_ <- forM msgContent_ $ \mc -> case requestSharedMsgId of Just smId -> pure (smId, mc) Nothing -> do @@ -2297,6 +2304,7 @@ processChatCommand cxt nm = \case case gInfo of GroupInfo {preparedGroup = Nothing} -> throwCmdError "group doesn't have link to connect" GroupInfo {useRelays = BoolDef True, preparedGroup = Just PreparedGroup {connLinkToConnect}} -> do + checkLinkNotice $ connFullLink connLinkToConnect sLnk <- case connShortLink' connLinkToConnect of Just sl -> pure sl Nothing -> throwChatError $ CEException "failed to retrieve relays: no short link" @@ -2359,6 +2367,7 @@ processChatCommand cxt nm = \case newConnIds <- getAgentConnShortLinkAsync user CFGetRelayDataJoin Nothing relayLink withStore' $ \db -> createRelayMemberConnectionAsync db user gInfo' relayMember relayLink newConnIds subMode GroupInfo {preparedGroup = Just PreparedGroup {connLinkToConnect, welcomeSharedMsgId, requestSharedMsgId}} -> do + checkLinkNotice $ connFullLink connLinkToConnect hostMember <- withFastStore $ \db -> getHostMember db cxt user groupId msg_ <- forM msgContent_ $ \mc -> case requestSharedMsgId of Just smId -> pure (smId, mc) @@ -2388,10 +2397,11 @@ processChatCommand cxt nm = \case (conn, incognitoProfile) <- connectViaInvitation user incognito ccLink Nothing let pcc = mkPendingContactConnection conn $ Just ccLink pure $ CRSentConfirmation user pcc incognitoProfile - ACCL SCMContact ccLink@(CCLink _ sLnk) -> do + ACCL SCMContact ccLink@(CCLink cReq sLnk) -> do case sLnk of Just (CSLContact _ CCTChannel _ _) -> throwChatError $ CECommandError "channel links must be connected via APIConnectPreparedGroup" _ -> pure () + checkLinkNotice cReq connectViaContact user Nothing incognito ccLink Nothing Nothing >>= \case CVRConnectedContact ct -> pure $ CRContactAlreadyExists user ct CVRSentInvitation conn incognitoProfile -> pure $ CRSentInvitation user (mkPendingContactConnection conn Nothing) incognitoProfile @@ -2443,7 +2453,7 @@ processChatCommand cxt nm = \case ConnectSimplex incognito -> withUser $ \user -> do plan <- contactRequestPlan user adminContactReq Nothing Nothing `catchAllErrors` const (pure $ CPContactAddress (CAPOk Nothing Nothing)) connectWithPlan user incognito (ACCL SCMContact (CCLink adminContactReq Nothing)) Nothing Nothing plan - DeleteContact cName cdm -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId Nothing) cdm + DeleteContact cName cdm -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId Nothing) cdm Nothing ClearContact cName -> withContactName cName $ \chatId -> APIClearChat $ ChatRef CTDirect chatId Nothing APIListContacts userId -> withUserId userId $ \user -> CRContactsList user <$> withFastStore' (\db -> getUserContacts db cxt user) @@ -3071,7 +3081,7 @@ processChatCommand cxt nm = \case let content = CISndGroupEvent $ SGEMemberBlocked groupMemberId (fromLocalProfile memberProfile) blockFlag ts = ciContentTexts content in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing - APIRemoveMembers {groupId, groupMemberIds, withMessages} -> withUser $ \user -> + APIRemoveMembers {groupId, groupMemberIds, withMessages, notice} -> withUser $ \user -> withGroupLock "removeMembers" groupId $ do -- TODO [relays] possible optimization is to read only required members + relays (Group gInfo members, gks) <- withFastStore $ \db -> getGroupKeys_ db cxt user groupId @@ -3145,7 +3155,7 @@ processChatCommand cxt nm = \case Nothing -> pure ([], [], [], False) Just memsToDelete' -> do let chatScope = toChatScope <$> chatScopeInfo - events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages rosterVer) memsToDelete' + events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages rosterVer notice) memsToDelete' (msgs_, _gsr) <- sendGroupMessages user g chatScope False recipients False events let signed = any (either (const False) (\SndMessage {signedMsg_} -> isJust signedMsg_)) msgs_ itemsData_ = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_) @@ -3245,12 +3255,12 @@ processChatCommand cxt nm = \case AcceptMember gName gMemberName memRole -> withMemberName gName gMemberName $ \gId gMemberId -> APIAcceptMember gId gMemberId memRole MemberRole gName gMemberName memRole -> withMemberName gName gMemberName $ \gId gMemberId -> APIMembersRole gId [gMemberId] memRole BlockForAll gName gMemberName blocked -> withMemberName gName gMemberName $ \gId gMemberId -> APIBlockMembersForAll gId [gMemberId] blocked - RemoveMembers gName gMemberNames withMessages -> withUser $ \user -> do + RemoveMembers gName gMemberNames withMessages notice -> withUser $ \user -> do (gId, gMemberIds) <- withStore $ \db -> do gId <- getGroupIdByName db user gName gMemberIds <- mapM (getGroupMemberIdByName db user gId) gMemberNames pure (gId, gMemberIds) - processChatCommand cxt nm $ APIRemoveMembers gId gMemberIds withMessages + processChatCommand cxt nm $ APIRemoveMembers gId gMemberIds withMessages notice LeaveGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand cxt nm $ APILeaveGroup groupId @@ -3259,7 +3269,7 @@ processChatCommand cxt nm = \case processChatCommand cxt nm $ APIAllowRelayGroup groupId DeleteGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand cxt nm $ APIDeleteChat (ChatRef CTGroup groupId Nothing) (CDMFull True) + processChatCommand cxt nm $ APIDeleteChat (ChatRef CTGroup groupId Nothing) (CDMFull True) Nothing ClearGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand cxt nm $ APIClearChat (ChatRef CTGroup groupId Nothing) @@ -3898,7 +3908,8 @@ processChatCommand cxt nm = \case conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ pqSup pure $ CVRSentInvitation conn' incognitoProfile connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> CreatedLinkContact -> CM ChatResponse - connectContactViaAddress user@User {userId} incognito ct@Contact {contactId, activeConn} (CCLink cReq shortLink) = + connectContactViaAddress user@User {userId} incognito ct@Contact {contactId, activeConn} (CCLink cReq shortLink) = do + checkLinkNotice cReq withInvitationLock "connectContactViaAddress" (strEncode cReq) $ case activeConn of Nothing -> do @@ -4638,11 +4649,13 @@ processChatCommand cxt nm = \case contactRequestPlan :: User -> ConnReqContact -> Maybe ContactShortLinkData -> Maybe OwnerVerification -> CM ConnectionPlan contactRequestPlan user cReq cld ov = do let cReqSchemas = contactCReqSchemas cReq - cReqHashes = bimap contactCReqHash contactCReqHash cReqSchemas + cReqHashes = contactCReqHashes cReq plan p = pure $ CPContactAddress p - withFastStore' (\db -> getUserContactLinkByConnReq db user cReqSchemas) >>= \case - Just _ -> plan $ CAPOwnLink - Nothing -> + ts <- liftIO getCurrentTime + withFastStore' (\db -> (,) <$> getUserContactLinkByConnReq db user cReqSchemas <*> getLinkNotice db ts cReqHashes) >>= \case + (Just _, _) -> plan $ CAPOwnLink + (Nothing, Just (expiresAt, reason)) -> plan $ CAPLinkNotice expiresAt reason + (Nothing, Nothing) -> withFastStore' (\db -> getContactConnEntityByConnReqHash db cxt user cReqHashes) >>= \case Nothing -> withFastStore' (\db -> getContactWithoutConnViaAddress db cxt user cReqSchemas) >>= \case @@ -4661,11 +4674,13 @@ processChatCommand cxt nm = \case groupJoinRequestPlan :: User -> ConnReqContact -> Maybe GroupShortLinkInfo -> Maybe GroupShortLinkData -> Maybe OwnerVerification -> [GroupLinkOwner] -> CM ConnectionPlan groupJoinRequestPlan user cReq linkInfo gld ov glOwners = do let cReqSchemas = contactCReqSchemas cReq - cReqHashes = bimap contactCReqHash contactCReqHash cReqSchemas + cReqHashes = contactCReqHashes cReq plan p = pure $ CPGroupLink p - withFastStore' (\db -> getGroupInfoByUserContactLinkConnReq db cxt user cReqSchemas) >>= \case - Just g -> plan $ GLPOwnLink g - Nothing -> do + ts <- liftIO getCurrentTime + withFastStore' (\db -> (,) <$> getGroupInfoByUserContactLinkConnReq db cxt user cReqSchemas <*> getLinkNotice db ts cReqHashes) >>= \case + (Just g, _) -> plan $ GLPOwnLink g + (Nothing, Just (expiresAt, reason)) -> plan $ GLPLinkNotice expiresAt reason + (Nothing, Nothing) -> do connEnt_ <- withFastStore' $ \db -> getContactConnEntityByConnReqHash db cxt user cReqHashes gInfo_ <- withFastStore' $ \db -> getGroupInfoByGroupLinkHash db cxt user cReqHashes case (gInfo_, connEnt_) of @@ -4691,6 +4706,13 @@ processChatCommand cxt nm = \case ( CRContactUri crData {crScheme = SSSimplex} e2e, CRContactUri crData {crScheme = simplexChat} e2e ) + contactCReqHashes :: ConnReqContact -> (ConnReqUriHash, ConnReqUriHash) + contactCReqHashes = bimap contactCReqHash contactCReqHash . contactCReqSchemas + checkLinkNotice :: ConnReqContact -> CM () + checkLinkNotice cReq = do + ts <- liftIO getCurrentTime + withFastStore' (\db -> getLinkNotice db ts $ contactCReqHashes cReq) + >>= mapM_ (\(expiresAt, reason) -> throwChatError $ CELinkNotice expiresAt reason) -- This function is needed, as UI uses simplex:/ schema in message view, so that the links can be handled without browser, -- and short links are stored with server hostname schema, so they wouldn't match without it. serverShortLink :: ConnShortLink m -> ConnShortLink m @@ -5818,6 +5840,7 @@ cleanupManager = do cleanupDeliveryJobs `catchAllErrors` eToView -- TODO possibly, also cleanup async commands cleanupProbes `catchAllErrors` eToView + cleanupLinkNotices `catchAllErrors` eToView liftIO $ threadDelay' $ diffToMicroseconds interval where runWithoutInitialDelay cleanupInterval = flip catchAllErrors eToView $ do @@ -5887,6 +5910,9 @@ cleanupManager = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (-(14 * nominalDay)) ts withStore' (`deleteOldProbes` cutoffTs) + cleanupLinkNotices = do + ts <- liftIO getCurrentTime + withStore' (`deleteExpiredLinkNotices` ts) deleteInProgressGroup :: User -> GroupInfo -> CM () deleteInProgressGroup user gInfo = do @@ -6103,7 +6129,7 @@ chatCommandP = "/_read chat " *> (APIChatRead <$> chatRefP), "/_read chat items " *> (APIChatItemsRead <$> chatRefP <*> _strP), "/_unread chat " *> (APIChatUnread <$> chatRefP <* A.space <*> onOffP), - "/_delete " *> (APIDeleteChat <$> chatRefP <*> chatDeleteMode), + "/_delete " *> (APIDeleteChat <$> chatRefP <*> chatDeleteMode <*> optional (" notice=" *> jsonP)), "/_clear chat " *> (APIClearChat <$> chatRefP), "/_accept" *> (APIAcceptContact <$> incognitoOnOffP <* A.space <*> A.decimal), "/_reject " *> (APIRejectContact <$> A.decimal <*> (" notify=" *> onOffP <|> pure False)), @@ -6145,7 +6171,7 @@ chatCommandP = "/_delete member chat #" *> (APIDeleteMemberSupportChat <$> A.decimal <* A.space <*> A.decimal), "/_member role #" *> (APIMembersRole <$> A.decimal <*> _strP <*> memberRole), "/_block #" *> (APIBlockMembersForAll <$> A.decimal <*> _strP <* " blocked=" <*> onOffP), - "/_remove #" *> (APIRemoveMembers <$> A.decimal <*> _strP <*> (" messages=" *> onOffP <|> pure False)), + "/_remove #" *> (APIRemoveMembers <$> A.decimal <*> _strP <*> (" messages=" *> onOffP <|> pure False) <*> optional (" notice=" *> jsonP)), "/_leave #" *> (APILeaveGroup <$> A.decimal), "/_members #" *> (APIListMembers <$> A.decimal), -- "/_archive conversations #" *> (APIArchiveGroupConversations <$> A.decimal <*> _strP), @@ -6245,7 +6271,7 @@ chatCommandP = ("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> memberRole), "/block for all #" *> (BlockForAll <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure True), "/unblock for all #" *> (BlockForAll <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure False), - ("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMembers <$> displayNameP <* A.space <*> (L.fromList <$> (char_ '@' *> displayNameP) `A.sepBy1'` A.char ',') <*> (" messages=" *> onOffP <|> pure False)), + ("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMembers <$> displayNameP <* A.space <*> (L.fromList <$> (char_ '@' *> displayNameP) `A.sepBy1'` A.char ',') <*> (" messages=" *> onOffP <|> pure False) <*> optional (" notice=" *> jsonP)), ("/leave " <|> "/l ") *> char_ '#' *> (LeaveGroup <$> displayNameP), ("/delete #" <|> "/d #") *> (DeleteGroup <$> displayNameP), ("/delete " <|> "/d ") *> char_ '@' *> (DeleteContact <$> displayNameP <*> chatDeleteMode), diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 1dbf1adcb9..a4618a8ba6 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -584,7 +584,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag XFileCancel sharedMsgId -> xFileCancel ct'' sharedMsgId XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInv ct'' sharedMsgId fileConnReq_ fName XInfo p _ -> xInfo ct'' p - XDirectDel -> xDirectDel ct'' msg msgMeta + XDirectDel silent notice_ -> xDirectDel ct'' silent notice_ msg msgMeta XGrpInv gInv -> processGroupInvitation ct'' gInv msg msgMeta XInfoProbe probe -> xInfoProbe (COMContact ct'') probe XInfoProbeCheck probeHash -> xInfoProbeCheck (COMContact ct'') probeHash @@ -1093,11 +1093,11 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag XGrpMemRole memId memRole memberKey rosterVer -> fmap ctx <$> xGrpMemRole (GIK gInfo' gks) Nothing m'' memId memRole memberKey rosterVer msg brokerTs XGrpMemRestrict memId memRestrictions -> fmap ctx <$> xGrpMemRestrict gInfo' m'' memId memRestrictions msg brokerTs XGrpMemCon memId -> Nothing <$ xGrpMemCon gInfo' m'' memId - XGrpMemDel memId withMessages rosterVer -> case encoding @e of - SJson -> fmap ctx <$> xGrpMemDel (GIK gInfo' gks) Nothing m'' memId withMessages rosterVer verifiedMsg msg brokerTs False + XGrpMemDel memId withMessages rosterVer notice_ -> case encoding @e of + SJson -> fmap ctx <$> xGrpMemDel (GIK gInfo' gks) Nothing m'' memId withMessages rosterVer notice_ verifiedMsg msg brokerTs False SBinary -> pure Nothing XGrpLeave -> fmap ctx <$> xGrpLeave (GIK gInfo' gks) m'' msg brokerTs - XGrpDel -> Just (DeliveryTaskContext (DJSGroup {jobSpec = DJRelayRemoved}) False) <$ xGrpDel gInfo' m'' msg brokerTs + XGrpDel notice_ -> Just (DeliveryTaskContext (DJSGroup {jobSpec = DJRelayRemoved}) False) <$ xGrpDel gInfo' m'' notice_ msg brokerTs XGrpInfo p' -> fmap ctx <$> xGrpInfo (GIK gInfo' gks) m'' p' msg brokerTs XGrpPrefs ps' -> fmap ctx <$> xGrpPrefs gInfo' m'' ps' msg XGrpRoster gr -> fmap ctx <$> xGrpRoster gInfo' m'' m'' gr verifiedMsg sharedMsgId_ brokerTs @@ -2731,24 +2731,26 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag xInfo :: Contact -> Profile -> CM () xInfo c p' = void $ processContactProfileUpdate c p' True - xDirectDel :: Contact -> RcvMessage -> MsgMeta -> CM () - xDirectDel c msg msgMeta = - if directOrUsed c - then do - (ct', contactConns) <- withStore' $ \db -> do - ct' <- updateContactStatus db user c CSDeleted - (ct',) <$> getContactConnections db cxt userId ct' - deleteAgentConnectionsAsync $ map aConnId contactConns - forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted - activeConn' <- forM (contactConn ct') $ \conn -> pure conn {connStatus = ConnDeleted} - let ct'' = ct' {activeConn = activeConn'} :: Contact - (ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct'') msg brokerTs (CIRcvDirectEvent RDEContactDeleted) - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] - toView $ CEvtContactDeletedByContact user ct'' - else do - contactConns <- withStore' $ \db -> getContactConnections db cxt userId c - deleteAgentConnectionsAsync $ map aConnId contactConns - withStore $ \db -> deleteContact db user c + xDirectDel :: Contact -> Bool -> Maybe LinkNotice -> RcvMessage -> MsgMeta -> CM () + xDirectDel c silent notice_ msg msgMeta = do + forM_ notice_ $ \notice -> withStore' $ \db -> setContactLinkNotice db c brokerTs notice + unless silent $ + if directOrUsed c + then do + (ct', contactConns) <- withStore' $ \db -> do + ct' <- updateContactStatus db user c CSDeleted + (ct',) <$> getContactConnections db cxt userId ct' + deleteAgentConnectionsAsync $ map aConnId contactConns + forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted + activeConn' <- forM (contactConn ct') $ \conn -> pure conn {connStatus = ConnDeleted} + let ct'' = ct' {activeConn = activeConn'} :: Contact + (ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct'') msg brokerTs (CIRcvDirectEvent RDEContactDeleted) + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] + toView $ CEvtContactDeletedByContact user ct'' + else do + contactConns <- withStore' $ \db -> getContactConnections db cxt userId c + deleteAgentConnectionsAsync $ map aConnId contactConns + withStore $ \db -> deleteContact db user c where brokerTs = metaBrokerTs msgMeta @@ -3678,8 +3680,8 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag withStore $ \db -> setMemberVectorRelationConnected db sendingMem refMem MRSubjectConnected withStore $ \db -> setMemberVectorRelationConnected db refMem sendingMem MRReferencedConnected - xGrpMemDel :: GroupInfoKeys -> Maybe GroupMember -> GroupMember -> MemberId -> Bool -> Maybe VersionRoster -> VerifiedMsg 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope) - xGrpMemDel g@(GIK gInfo@GroupInfo {membership} gks) fwdRelay_ m@GroupMember {memberRole = senderRole} memId withMessages rosterVer_ verifiedMsg msg@RcvMessage {msgSigned} brokerTs forwarded = do + xGrpMemDel :: GroupInfoKeys -> Maybe GroupMember -> GroupMember -> MemberId -> Bool -> Maybe VersionRoster -> Maybe LinkNotice -> VerifiedMsg 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope) + xGrpMemDel g@(GIK gInfo@GroupInfo {membership} gks) fwdRelay_ m@GroupMember {memberRole = senderRole} memId withMessages rosterVer_ notice_ verifiedMsg msg@RcvMessage {msgSigned} brokerTs forwarded = do let GroupMember {memberId = membershipMemId} = membership if membershipMemId == memId then applyAtRosterVersion g fwdRelay_ m rosterVer_ $ checkRole membership $ do @@ -3689,6 +3691,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag withStore' $ \db -> do updateGroupMemberStatus db userId membership GSMemRemoved when (maybe False (/= RSRejected) (relayOwnStatus gInfo)) $ updateRelayOwnStatus_ db gInfo RSInactive + forM_ notice_ $ setGroupLinkNotice db gInfo brokerTs let membership' = membership {memberStatus = GSMemRemoved} when withMessages $ deleteMessages gInfo membership' deleteMemberItem msg gInfo RGEUserDeleted @@ -3775,11 +3778,13 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag toView $ CEvtLeftMember user gInfo''' m' {memberStatus = GSMemLeft} msgSigned pure $ memberEventDeliveryScope m - xGrpDel :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM () - xGrpDel gInfo@GroupInfo {membership} m@GroupMember {memberRole} msg@RcvMessage {msgSigned} brokerTs = do + xGrpDel :: GroupInfo -> GroupMember -> Maybe LinkNotice -> RcvMessage -> UTCTime -> CM () + xGrpDel gInfo@GroupInfo {membership} m@GroupMember {memberRole} notice_ msg@RcvMessage {msgSigned} brokerTs = do when (memberRole /= GROwner) $ throwChatError $ CEGroupUserRole gInfo GROwner deleteGroupLinkIfExists user gInfo - withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemGroupDeleted + withStore' $ \db -> do + updateGroupMemberStatus db userId membership GSMemGroupDeleted + forM_ notice_ $ setGroupLinkNotice db gInfo brokerTs -- TODO [relays] possible improvement is to immediately delete rcv queues if isUserGrpFwdRelay unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections user gInfo False (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo m @@ -3974,9 +3979,9 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag XGrpMemNew memInfo msgScope -> withAuthor XGrpMemNew_ $ \author -> void $ xGrpMemNew g author memInfo msgScope rcvMsg msgTs XGrpMemRole memId memRole memberKey rosterVer -> withAuthor XGrpMemRole_ $ \author -> void $ xGrpMemRole g (Just m) author memId memRole memberKey rosterVer rcvMsg msgTs XGrpMemRestrict memId memRestrictions -> withAuthor XGrpMemRestrict_ $ \author -> void $ xGrpMemRestrict gInfo author memId memRestrictions rcvMsg msgTs - XGrpMemDel memId withMessages rosterVer -> withAuthor XGrpMemDel_ $ \author -> void $ xGrpMemDel g (Just m) author memId withMessages rosterVer verifiedMsg rcvMsg msgTs True + XGrpMemDel memId withMessages rosterVer notice_ -> withAuthor XGrpMemDel_ $ \author -> void $ xGrpMemDel g (Just m) author memId withMessages rosterVer notice_ verifiedMsg rcvMsg msgTs True XGrpLeave -> withAuthor XGrpLeave_ $ \author -> void $ xGrpLeave g author rcvMsg msgTs - XGrpDel -> withAuthor XGrpDel_ $ \author -> void $ xGrpDel gInfo author rcvMsg msgTs + XGrpDel notice_ -> withAuthor XGrpDel_ $ \author -> void $ xGrpDel gInfo author notice_ rcvMsg msgTs XGrpInfo p' -> withAuthor XGrpInfo_ $ \author -> void $ xGrpInfo g author p' rcvMsg msgTs XGrpPrefs ps' -> withAuthor XGrpPrefs_ $ \author -> void $ xGrpPrefs gInfo author ps' rcvMsg XGrpRoster gr -> withAuthor XGrpRoster_ $ \author -> void $ xGrpRoster gInfo m author gr verifiedMsg sharedMsgId_ msgTs diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 4671a927d1..2526f2015b 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -92,7 +92,7 @@ import Simplex.Messaging.Version hiding (version) -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: VersionChat -currentChatVersion = VersionChat 20 +currentChatVersion = VersionChat 21 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -140,6 +140,9 @@ groupRosterVersion = VersionChat 19 groupMemberKeyVersion :: VersionChat groupMemberKeyVersion = VersionChat 20 +linkNoticeVersion :: VersionChat +linkNoticeVersion = VersionChat 21 + data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} | RcvGroupMsgConnection {entityConnection :: Connection, groupInfo :: GroupInfo, groupMember :: GroupMember} @@ -288,6 +291,7 @@ instance StrEncoding ReportReason where A.takeTill (== ' ') >>= \case "spam" -> pure RRSpam "content" -> pure RRContent + "illegal" -> pure RRContent "community" -> pure RRCommunity "profile" -> pure RRProfile "other" -> pure RROther @@ -300,6 +304,18 @@ instance ToJSON ReportReason where toJSON = strToJSON toEncoding = strToJEncoding +instance ToField ReportReason where toField = toField . safeDecodeUtf8 . strEncode + +instance FromField ReportReason where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 + +data LinkNotice = LinkNotice + { ttl :: Maybe Int64, + reason :: Maybe ReportReason + } + deriving (Eq, Show) + +$(JQ.deriveJSON defaultJSON ''LinkNotice) + data ChatMessage e = ChatMessage { chatVRange :: VersionRangeChat, msgId :: Maybe SharedMsgId, @@ -463,7 +479,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XInfo :: {profile :: Profile, memberKey :: Maybe MemberKey} -> ChatMsgEvent 'Json XContact :: {profile :: Profile, memberKey :: Maybe MemberKey, contactReqId :: Maybe XContactId, welcomeMsgId :: Maybe SharedMsgId, requestMsg :: Maybe (SharedMsgId, MsgContent)} -> ChatMsgEvent 'Json XMember :: {profile :: Profile, newMemberId :: MemberId, newMemberKey :: MemberKey, viaRelay :: Maybe MemberId} -> ChatMsgEvent 'Json - XDirectDel :: ChatMsgEvent 'Json + XDirectDel :: Bool -> Maybe LinkNotice -> ChatMsgEvent 'Json XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json XGrpAcpt :: MemberId -> Maybe MemberKey -> ChatMsgEvent 'Json XGrpLinkInv :: GroupLinkInvitation -> ChatMsgEvent 'Json @@ -485,9 +501,9 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpMemRestrict :: MemberId -> MemberRestrictions -> ChatMsgEvent 'Json XGrpMemCon :: MemberId -> ChatMsgEvent 'Json XGrpMemConAll :: MemberId -> ChatMsgEvent 'Json -- TODO not implemented - XGrpMemDel :: MemberId -> Bool -> Maybe VersionRoster -> ChatMsgEvent 'Json + XGrpMemDel :: MemberId -> Bool -> Maybe VersionRoster -> Maybe LinkNotice -> ChatMsgEvent 'Json XGrpLeave :: ChatMsgEvent 'Json - XGrpDel :: ChatMsgEvent 'Json + XGrpDel :: Maybe LinkNotice -> ChatMsgEvent 'Json XGrpInfo :: GroupProfile -> ChatMsgEvent 'Json XGrpPrefs :: GroupPreferences -> ChatMsgEvent 'Json XGrpDirectInv :: ConnReqInvitation -> Maybe MsgContent -> Maybe MsgScope -> ChatMsgEvent 'Json @@ -534,7 +550,7 @@ isForwardedGroupMsg ev = case ev of XGrpMemRestrict {} -> True XGrpMemDel {} -> True XGrpLeave -> True - XGrpDel -> True + XGrpDel _ -> True XGrpInfo _ -> True XGrpPrefs _ -> True XGrpRoster _ -> True @@ -1256,7 +1272,7 @@ toCMEventTag msg = case msg of XInfo {} -> XInfo_ XContact {} -> XContact_ XMember {} -> XMember_ - XDirectDel -> XDirectDel_ + XDirectDel {} -> XDirectDel_ XGrpInv _ -> XGrpInv_ XGrpAcpt {} -> XGrpAcpt_ XGrpLinkInv _ -> XGrpLinkInv_ @@ -1280,7 +1296,7 @@ toCMEventTag msg = case msg of XGrpMemConAll _ -> XGrpMemConAll_ XGrpMemDel {} -> XGrpMemDel_ XGrpLeave -> XGrpLeave_ - XGrpDel -> XGrpDel_ + XGrpDel _ -> XGrpDel_ XGrpInfo _ -> XGrpInfo_ XGrpPrefs _ -> XGrpPrefs_ XGrpDirectInv {} -> XGrpDirectInv_ @@ -1425,7 +1441,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do let requestMsg = (,) <$> reqMsgId <*> reqContent pure XContact {profile, memberKey, contactReqId, welcomeMsgId, requestMsg} XMember_ -> XMember <$> p "profile" <*> p "newMemberId" <*> p "newMemberKey" <*> opt "viaRelay" - XDirectDel_ -> pure XDirectDel + XDirectDel_ -> XDirectDel <$> Right (fromRight False $ p "silent") <*> Right (fromRight Nothing $ opt "notice") XGrpInv_ -> XGrpInv <$> p "groupInvitation" XGrpAcpt_ -> XGrpAcpt <$> p "memberId" <*> opt "memberKey" XGrpLinkInv_ -> XGrpLinkInv <$> p "groupLinkInvitation" @@ -1450,9 +1466,9 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpMemRestrict_ -> XGrpMemRestrict <$> p "memberId" <*> p "memberRestrictions" XGrpMemCon_ -> XGrpMemCon <$> p "memberId" XGrpMemConAll_ -> XGrpMemConAll <$> p "memberId" - XGrpMemDel_ -> XGrpMemDel <$> p "memberId" <*> Right (fromRight False $ p "messages") <*> opt "rosterVersion" + XGrpMemDel_ -> XGrpMemDel <$> p "memberId" <*> Right (fromRight False $ p "messages") <*> opt "rosterVersion" <*> Right (fromRight Nothing $ opt "notice") XGrpLeave_ -> pure XGrpLeave - XGrpDel_ -> pure XGrpDel + XGrpDel_ -> XGrpDel <$> Right (fromRight Nothing $ opt "notice") XGrpInfo_ -> XGrpInfo <$> p "groupProfile" XGrpPrefs_ -> XGrpPrefs <$> p "groupPreferences" XGrpDirectInv_ -> XGrpDirectInv <$> p "connReq" <*> opt "content" <*> opt "scope" @@ -1501,7 +1517,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en XInfo {profile, memberKey} -> o $ ("memberKey" .=? memberKey) ["profile" .= profile] XContact {profile, memberKey, contactReqId, welcomeMsgId, requestMsg} -> o $ ("contactReqId" .=? contactReqId) $ ("welcomeMsgId" .=? welcomeMsgId) $ ("msgId" .=? (fst <$> requestMsg)) $ ("content" .=? (snd <$> requestMsg)) $ ("memberKey" .=? memberKey) $ ["profile" .= profile] XMember {profile, newMemberId, newMemberKey, viaRelay} -> o $ ("viaRelay" .=? viaRelay) ["profile" .= profile, "newMemberId" .= newMemberId, "newMemberKey" .= newMemberKey] - XDirectDel -> JM.empty + XDirectDel silent notice -> o $ ("silent" .=? justTrue silent) $ ("notice" .=? notice) [] XGrpInv groupInv -> o ["groupInvitation" .= groupInv] XGrpAcpt memId memberKey -> o $ ("memberKey" .=? memberKey) ["memberId" .= memId] XGrpLinkInv groupLinkInv -> o ["groupLinkInvitation" .= groupLinkInv] @@ -1525,9 +1541,9 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en XGrpMemRestrict memId memRestrictions -> o ["memberId" .= memId, "memberRestrictions" .= memRestrictions] XGrpMemCon memId -> o ["memberId" .= memId] XGrpMemConAll memId -> o ["memberId" .= memId] - XGrpMemDel memId messages rosterVersion -> o $ ("rosterVersion" .=? rosterVersion) $ ("messages" .=? if messages then Just True else Nothing) ["memberId" .= memId] + XGrpMemDel memId messages rosterVersion notice -> o $ ("notice" .=? notice) $ ("rosterVersion" .=? rosterVersion) $ ("messages" .=? if messages then Just True else Nothing) ["memberId" .= memId] XGrpLeave -> JM.empty - XGrpDel -> JM.empty + XGrpDel notice -> o $ ("notice" .=? notice) [] XGrpInfo p -> o ["groupProfile" .= p] XGrpPrefs p -> o ["groupPreferences" .= p] XGrpDirectInv connReq content scope -> o $ ("content" .=? content) $ ("scope" .=? scope) ["connReq" .= connReq] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 24ed45c9a9..1d6f7c6519 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -53,6 +53,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260904_file_badges import Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges import Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors import Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json +import Simplex.Chat.Store.Postgres.Migrations.M20260926_link_notices import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -105,7 +106,8 @@ schemaMigrations = ("20260904_file_badges", m20260904_file_badges, Just down_m20260904_file_badges), ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges), ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors), - ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json) + ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), + ("20260926_link_notices", m20260926_link_notices, Just down_m20260926_link_notices) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index ad975874db..e2a5bb05f1 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -1229,6 +1229,28 @@ ALTER TABLE test_chat_schema.known_servers ALTER COLUMN server_id ADD GENERATED +CREATE TABLE test_chat_schema.link_notices ( + link_notice_id bigint NOT NULL, + link_hash bytea NOT NULL, + expires_at timestamp with time zone, + reason text, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + + + +ALTER TABLE test_chat_schema.link_notices ALTER COLUMN link_notice_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.link_notices_link_notice_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + CREATE TABLE test_chat_schema.messages ( message_id bigint NOT NULL, msg_sent smallint NOT NULL, @@ -2003,6 +2025,11 @@ ALTER TABLE ONLY test_chat_schema.known_servers +ALTER TABLE ONLY test_chat_schema.link_notices + ADD CONSTRAINT link_notices_pkey PRIMARY KEY (link_notice_id); + + + ALTER TABLE ONLY test_chat_schema.messages ADD CONSTRAINT messages_pkey PRIMARY KEY (message_id); @@ -2760,6 +2787,10 @@ CREATE INDEX idx_groups_via_group_link_uri_hash ON test_chat_schema.groups USING +CREATE UNIQUE INDEX idx_link_notices_link_hash ON test_chat_schema.link_notices USING btree (link_hash); + + + CREATE INDEX idx_messages_author_group_member_id ON test_chat_schema.messages USING btree (author_group_member_id); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 4dcefa1598..1250487405 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -176,6 +176,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260904_file_badges import Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges import Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors import Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json +import Simplex.Chat.Store.SQLite.Migrations.M20260926_link_notices import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -351,7 +352,8 @@ schemaMigrations = ("20260904_file_badges", m20260904_file_badges, Just down_m20260904_file_badges), ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges), ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors), - ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json) + ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), + ("20260926_link_notices", m20260926_link_notices, Just down_m20260926_link_notices) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 152d6c0c8d..2cea4ebe55 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -3847,6 +3847,17 @@ Query: Plan: SEARCH badge_ledger USING INDEX idx_badge_ledger_purchase (badge_purchase_id=?) +Query: + SELECT expires_at, reason + FROM link_notices + WHERE link_hash IN (?,?) AND (expires_at IS NULL OR expires_at > ?) + ORDER BY expires_at IS NULL DESC, expires_at DESC + LIMIT 1 + +Plan: +SEARCH link_notices USING INDEX idx_link_notices_link_hash (link_hash=?) +USE TEMP B-TREE FOR ORDER BY + Query: SELECT f.file_id FROM files f @@ -5064,6 +5075,16 @@ Query: Plan: +Query: + INSERT INTO link_notices (link_hash, expires_at, reason, created_at, updated_at) + VALUES (?,?,?,?,?) + ON CONFLICT (link_hash) DO UPDATE SET + expires_at = EXCLUDED.expires_at, + reason = EXCLUDED.reason, + updated_at = EXCLUDED.updated_at + +Plan: + Query: INSERT INTO msg_deliveries (message_id, connection_id, agent_msg_id, chat_ts, created_at, updated_at, delivery_status) @@ -7498,6 +7519,10 @@ Query: SELECT count(1) FROM group_members Plan: SCAN group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id +Query: SELECT count(1) FROM link_notices +Plan: +SCAN link_notices USING COVERING INDEX idx_link_notices_link_hash + Query: SELECT count(1) FROM pending_group_messages Plan: SCAN pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id @@ -7776,6 +7801,14 @@ Query: SELECT via_contact_uri, via_contact_uri_hash FROM connections WHERE conne Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT via_contact_uri_hash FROM connections WHERE connection_id = ? AND via_contact_uri_hash IS NOT NULL +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT via_group_link_uri_hash FROM groups WHERE group_id = ? AND via_group_link_uri_hash IS NOT NULL +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT xgrplinkmem_received FROM group_members WHERE group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 995497d5ee..1869d1308e 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -981,6 +981,14 @@ CREATE TABLE badge_code_redemptions( created_at TEXT NOT NULL, UNIQUE(user_id, code) ) STRICT; +CREATE TABLE link_notices( + link_notice_id INTEGER PRIMARY KEY AUTOINCREMENT, + link_hash BLOB NOT NULL, + expires_at TEXT, + reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -1544,6 +1552,7 @@ CREATE INDEX idx_badge_code_redemptions_user ON badge_code_redemptions( CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON badge_purchases( badge_code_redemption_id ); +CREATE UNIQUE INDEX idx_link_notices_link_hash ON link_notices(link_hash); CREATE TRIGGER on_group_members_insert_update_summary AFTER INSERT ON group_members FOR EACH ROW diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index c3d42de3d1..6a049bd035 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -30,11 +30,13 @@ import Data.Int (Int64) import Data.Maybe (fromMaybe, isJust, listToMaybe) import Data.Text (Text) import qualified Data.Text as T -import Data.Time.Clock (UTCTime (..), getCurrentTime) +import Data.Time.Calendar (fromGregorian) +import Data.Time.Clock (UTCTime (..), addUTCTime, getCurrentTime) import Data.Type.Equality import Simplex.Chat.Badges (BadgeRow, badgeToRow, rowToBadge, verifyBadge_) import Simplex.Chat.Names (SimplexDomainProof, SimplexDomainClaim (..), claimDomain) import Simplex.Chat.Messages +import Simplex.Chat.Protocol (LinkNotice (..), ReportReason) import Simplex.Chat.Remote.Types import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -986,6 +988,53 @@ setViaGroupLinkUri db groupId connId = do |] (viaContactUri, viaContactUriHash, groupId) +setGroupLinkNotice :: DB.Connection -> GroupInfo -> UTCTime -> LinkNotice -> IO () +setGroupLinkNotice db GroupInfo {groupId} brokerTs notice = + maybeFirstRow fromOnly (DB.query db "SELECT via_group_link_uri_hash FROM groups WHERE group_id = ? AND via_group_link_uri_hash IS NOT NULL" (Only groupId)) + >>= mapM_ (\linkHash -> upsertLinkNotice db linkHash brokerTs notice) + +setContactLinkNotice :: DB.Connection -> Contact -> UTCTime -> LinkNotice -> IO () +setContactLinkNotice db ct brokerTs notice = + forM_ (contactConn ct) $ \Connection {connId} -> + maybeFirstRow fromOnly (DB.query db "SELECT via_contact_uri_hash FROM connections WHERE connection_id = ? AND via_contact_uri_hash IS NOT NULL" (Only connId)) + >>= mapM_ (\linkHash -> upsertLinkNotice db linkHash brokerTs notice) + +upsertLinkNotice :: DB.Connection -> ConnReqUriHash -> UTCTime -> LinkNotice -> IO () +upsertLinkNotice db linkHash brokerTs LinkNotice {ttl, reason} = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + INSERT INTO link_notices (link_hash, expires_at, reason, created_at, updated_at) + VALUES (?,?,?,?,?) + ON CONFLICT (link_hash) DO UPDATE SET + expires_at = EXCLUDED.expires_at, + reason = EXCLUDED.reason, + updated_at = EXCLUDED.updated_at + |] + (linkHash, expiresAt, reason, currentTs, currentTs) + where + expiresAt = mfilter (< maxLinkNoticeExpiry) $ (\t -> addUTCTime (fromIntegral t) brokerTs) <$> ttl + maxLinkNoticeExpiry = UTCTime (fromGregorian 10000 1 1) 0 + +getLinkNotice :: DB.Connection -> UTCTime -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe (Maybe UTCTime, Maybe ReportReason)) +getLinkNotice db currentTs (linkHash1, linkHash2) = + maybeFirstRow id $ + DB.query + db + [sql| + SELECT expires_at, reason + FROM link_notices + WHERE link_hash IN (?,?) AND (expires_at IS NULL OR expires_at > ?) + ORDER BY expires_at IS NULL DESC, expires_at DESC + LIMIT 1 + |] + (linkHash1, linkHash2, currentTs) + +deleteExpiredLinkNotices :: DB.Connection -> UTCTime -> IO () +deleteExpiredLinkNotices db currentTs = + DB.execute db "DELETE FROM link_notices WHERE expires_at <= ?" (Only currentTs) + deleteConnectionRecord :: DB.Connection -> User -> Int64 -> IO () deleteConnectionRecord db User {userId} cId = do DB.execute db "DELETE FROM connections WHERE user_id = ? AND connection_id = ?" (userId, cId) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 1c62bc267b..5fb4161abe 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1880,6 +1880,11 @@ day = T.pack . formatTime defaultTimeLocale "%Y-%m-%d" dayTime :: UTCTime -> Text dayTime = T.pack . formatTime defaultTimeLocale "%Y-%m-%d %H:%M" +viewLinkNotice :: Maybe UTCTime -> Maybe ReportReason -> Text +viewLinkNotice expiresAt reason = + maybe "banned permanently" (("banned until " <>) . dayTime) expiresAt + <> maybe "" ((", reason: " <>) . safeDecodeUtf8 . strEncode) reason + viewContactInfo :: Contact -> Maybe ConnectionStats -> Maybe Profile -> [StyledString] viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink, localBadge, contactDomain, contactDomainVerified, description}, activeConn, uiThemes, customData} stats incognitoProfile = ["contact ID: " <> sShow contactId] @@ -2266,6 +2271,7 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case <> contactDomainLine ct <> ["use " <> ttyToContact' ct <> highlight' "" <> " to send messages"] CAPContactViaAddress ct -> [ctAddr ("known contact without connection " <> ttyContact' ct)] <> contactDomainLine ct + CAPLinkNotice expiresAt reason -> [ctAddr $ plain $ viewLinkNotice expiresAt reason] where ctAddr = ("contact address: " <>) addrOrBiz = \case @@ -2300,6 +2306,7 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case knownGroup prepared = grpOrBizLink g <> ": known " <> prepared <> grpOrBiz g <> " " <> ttyGroup' g GLPNoRelays _ -> [grpLink "channel has no active relays, please try to join later"] GLPUpdateRequired _ -> [grpLink "this group requires a newer version of the app, please upgrade"] + GLPLinkNotice expiresAt reason -> [grpLink $ plain $ viewLinkNotice expiresAt reason] where connecting g = [grpOrBizLink g <> ": connecting to " <> grpOrBiz g <> " " <> ttyGroup' g] grpLink = ("group link: " <>) @@ -2808,6 +2815,7 @@ viewChatError isCmd logLevel testView = \case <> maybe "" (\MsgMetaJSON {rcvId} -> ", agent msg rcv id: " <> show rcvId) msgMeta_ ] CEConnReqMessageProhibited -> ["message is not allowed with this connection link"] + CELinkNotice expiresAt reason -> ["connection link: " <> plain (viewLinkNotice expiresAt reason)] CEContactNotFound cName m_ -> viewContactNotFound cName m_ CEContactNotReady c -> [ttyContact' c <> ": not ready"] CEContactDisabled ct -> [ttyContact' ct <> ": disabled, to enable: " <> highlight ("/enable " <> viewContactName ct) <> ", to delete: " <> highlight ("/d " <> viewContactName ct)] diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 6a85f6f463..5f5f3a126a 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -67,6 +67,7 @@ chatDirectTests = do it "deleting contact deletes profile" testDeleteContactDeletesProfile it "delete contact keeping conversation" testDeleteContactKeepConversation it "delete conversation keeping contact" testDeleteConversationKeepContact + it "delete contact connected via invitation with notice" testDeleteContactInvitationNotice it "direct message quoted replies" testDirectMessageQuotedReply it "direct message update" testDirectMessageUpdate it "direct message edit history" testDirectMessageEditHistory @@ -603,6 +604,18 @@ testDeleteContactKeepConversation = bob ##> "@alice hey" bob <## "alice: not ready" +testDeleteContactInvitationNotice :: HasCallStack => TestParams -> IO () +testDeleteContactInvitationNotice = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice ##> "/_delete @2 messages notice={\"ttl\":86400}" + alice <## "bad chat command: notice requires deleting the contact" + alice ##> "/_delete @2 full notice={\"ttl\":86400}" + alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" + linkNoticesCount bob `shouldReturn` 0 + testDeleteConversationKeepContact :: HasCallStack => TestParams -> IO () testDeleteConversationKeepContact = testChat2 aliceProfile bobProfile $ diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 0cc697b012..d76a772cdb 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -141,6 +141,11 @@ chatGroupTests = do it "own group link" testPlanGroupLinkOwn it "group link without contact - connecting" testPlanGroupLinkConnecting it "re-join existing group after leaving" testPlanGroupLinkLeaveRejoin + it "removed member with notice can't re-join" testPlanGroupLinkRemovedNotice + it "removal notice expiry and replacement" testPlanGroupLinkRemovedNoticeReplaced + it "removal notice applies only to the link used to join" testPlanGroupLinkRemovedNoticeOtherLinks + it "group deletion without notice leaves notices unchanged" testPlanGroupLinkDeletedWithoutNotice + it "rejected pending member with notice can't re-join" testPlanGroupLinkRejectedNotice #if !defined(dbPostgres) -- TODO [postgres] restore from outdated db backup (same as in agent) describe "group message errors" $ do @@ -293,6 +298,7 @@ chatGroupTests = do it "should change member role (signed)" testChannelChangeRoleSigned it "should block member for all (signed)" testChannelBlockMemberSigned it "should remove member (signed)" testChannelRemoveMemberSigned + it "should prevent removed member with notice from re-joining" testChannelRemoveMemberNotice it "should verify member security code via membership keys" testChannelMemberSecurityCode it "should delete channel (signed)" testChannelDeleteGroupSigned it "should delete channel and clean up relay connections" testChannelDeleteGroupCleanup @@ -3035,6 +3041,172 @@ testPlanGroupLinkLeaveRejoin = bob <## "group link: known group #team_1" bob <## "use #team_1 to send messages" +testPlanGroupLinkRemovedNotice :: HasCallStack => TestParams -> IO () +testPlanGroupLinkRemovedNotice = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + gLink <- createTeamLink alice + joinTeamViaLink alice bob gLink "bob" + + alice ##> "/rm team bob notice={\"ttl\":86400,\"reason\":\"spam\"}" + removedFromTeam alice bob "bob" + + bob ##> ("/_connect plan 1 " <> gLink) + bannedLine bob "group link: " ", reason: spam" + bob ##> ("/_connect plan 1 " <> linkAnotherSchema gLink) + bannedLine bob "group link: " ", reason: spam" + bob ##> ("/c " <> gLink) + bannedLine bob "group link: " ", reason: spam" + bob ##> ("/_connect 1 " <> gLink) + bannedLine bob "connection link: " ", reason: spam" + + bob ##> "/d #team" + bob <## "#team: you deleted your local copy of the group" + bob ##> ("/_connect plan 1 " <> gLink) + bannedLine bob "group link: " ", reason: spam" + + bob ##> "/create user robert" + showActiveUser bob "robert" + bob ##> ("/_connect plan 2 " <> gLink) + bannedLine bob "group link: " ", reason: spam" + +testPlanGroupLinkRemovedNoticeReplaced :: HasCallStack => TestParams -> IO () +testPlanGroupLinkRemovedNoticeReplaced = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + gLink <- createTeamLink alice + joinTeamViaLink alice bob gLink "bob" + + alice ##> "/rm team bob" + removedFromTeam alice bob "bob" + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: ok to connect directly" + _sLinkData <- getTermLine bob + linkNoticesCount bob `shouldReturn` 0 + + bob ##> "/d #team" + bob <## "#team: you deleted your local copy of the group" + joinTeamViaLink alice bob gLink "bob_1" + + alice ##> "/rm team bob_1 notice={\"ttl\":0}" + removedFromTeam alice bob "bob_1" + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: ok to connect directly" + _sLinkData <- getTermLine bob + linkNoticesCount bob `shouldReturn` 1 + + bob ##> "/d #team" + bob <## "#team: you deleted your local copy of the group" + joinTeamViaLink alice bob gLink "bob_2" + + alice ##> "/rm team bob_2 notice={\"reason\":\"profile\"}" + removedFromTeam alice bob "bob_2" + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: banned permanently, reason: profile" + linkNoticesCount bob `shouldReturn` 1 + +testPlanGroupLinkRemovedNoticeOtherLinks :: HasCallStack => TestParams -> IO () +testPlanGroupLinkRemovedNoticeOtherLinks = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + gLink <- createTeamLink alice + joinTeamViaLink alice bob gLink "bob" + alice ##> "/g club" + alice <## "group #club is created" + alice <## "to add members use /a club or /create link #club" + alice ##> "/create link #club" + clubLink <- getGroupLink alice "club" GRMember True + + alice ##> "/rm team bob notice={\"ttl\":86400}" + removedFromTeam alice bob "bob" + bob ##> ("/_connect plan 1 " <> gLink) + bannedLine bob "group link: " "" + bob ##> ("/_connect plan 1 " <> clubLink) + bob <## "group link: ok to connect directly" + _sLinkData <- getTermLine bob + + createGroup2 "news" alice cath + alice ##> "/rm news cath notice={\"ttl\":86400}" + concurrentlyN_ + [ alice <## "#news: you removed cath from the group (signed)", + do + cath <## "#news: alice removed you from the group (signed)" + cath <## "use /d #news to delete the group" + ] + linkNoticesCount cath `shouldReturn` 0 + +testPlanGroupLinkDeletedWithoutNotice :: HasCallStack => TestParams -> IO () +testPlanGroupLinkDeletedWithoutNotice = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + gLink <- createTeamLink alice + joinTeamViaLink alice bob gLink "bob" + alice ##> "/d #team" + concurrentlyN_ + [ alice <## "#team: you deleted the group (signed)", + do + bob <## "#team: alice deleted the group (signed)" + bob <## "use /d #team to delete the local copy of the group" + ] + linkNoticesCount bob `shouldReturn` 0 + +testPlanGroupLinkRejectedNotice :: HasCallStack => TestParams -> IO () +testPlanGroupLinkRejectedNotice = + testChatCfg2 cfg aliceProfile bobProfile $ + \alice bob -> do + gLink <- createTeamLink alice + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob connected and pending approval, use /_accept member #1 2 to accept member", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group, pending approval" + ] + + alice ##> "/rm team bob notice={\"ttl\":3600,\"reason\":\"community\"}" + alice <## "#team: you removed bob from the group (signed)" + bob <## "#team: alice removed you from the group (signed)" + bob <## "use /d #team to delete the group" + + bob ##> ("/_connect plan 1 " <> gLink) + bannedLine bob "group link: " ", reason: community" + where + cfg = testCfg {chatHooks = defaultChatHooks {acceptMember = Just (\_ _ _ -> pure $ Right (GAPendingApproval, GRObserver))}} + +createTeamLink :: HasCallStack => TestCC -> IO String +createTeamLink alice = do + threadDelay 100000 + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + getGroupLink alice "team" GRMember True + +joinTeamViaLink :: HasCallStack => TestCC -> TestCC -> String -> String -> IO () +joinTeamViaLink alice bob gLink bobName = do + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## (bobName <> " (Bob): accepting request to join group #team...") + concurrentlyN_ + [ alice <## ("#team: " <> bobName <> " joined the group"), + bob + <### [ "#team: joining the group...", + "#team: you joined the group" + ] + ] + threadDelay 100000 + +removedFromTeam :: HasCallStack => TestCC -> TestCC -> String -> IO () +removedFromTeam alice bob bobName = + concurrentlyN_ + [ alice <## ("#team: you removed " <> bobName <> " from the group (signed)"), + do + bob <## "#team: alice removed you from the group (signed)" + bob <## "use /d #team to delete the group" + ] + testGroupLink :: HasCallStack => TestParams -> IO () testGroupLink = testChatOpts3 testOptsNoFullLinks aliceProfile bobProfile cathProfile $ @@ -10679,6 +10851,26 @@ testChannelRemoveMemberSigned ps = cath <## "group ID: 1" cath <## "subscribers: 2" +testChannelRemoveMemberNotice :: HasCallStack => TestParams -> IO () +testChannelRemoveMemberNotice ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + + threadDelay 1000000 + alice ##> "/rm #team cath notice={\"ttl\":86400,\"reason\":\"content\"}" + alice <## "#team: you removed cath from the group (signed)" + bob <## "#team: alice removed cath from the group (signed)" + cath <## "#team: alice removed you from the group (signed)" + cath <## "use /d #team to delete the group" + + cath ##> ("/_connect plan 1 " <> shortLink) + bannedLine cath "group link: " ", reason: content" + cath ##> "/_connect group #1" + bannedLine cath "connection link: " ", reason: content" + -- asserts the member row is GSMemRemoved, with removed_at set (TTL tombstone) or NULL (permanent) checkRemovedMember :: HasCallStack => TestCC -> String -> Bool -> Expectation checkRemovedMember cc name removedAtSet = do diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 2a4eeccb74..e8a0aa83ee 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -79,6 +79,7 @@ chatProfileTests = do describe "business address" $ do it "create and connect via business address" testBusinessAddress it "update profiles with business address" testBusinessUpdateProfiles + it "customer removed or chat deleted with notice can't re-connect" testBusinessAddressNotice describe "contact address connection plan" $ do it "contact address ok to connect; known contact" testPlanAddressOkKnown it "own contact address" testPlanAddressOwn @@ -86,6 +87,10 @@ chatProfileTests = do it "re-connect with deleted contact" testPlanAddressContactDeletedReconnected it "contact via address" testPlanAddressContactViaAddress it "contact via short address" testPlanAddressContactViaShortAddress + it "contact deleted silently with notice can't re-connect" testPlanAddressContactDeletedNoticeSilent + it "contact deleted with notification and notice can't re-connect" testPlanAddressContactDeletedNoticeNotify + it "silent notice is not sent to previous version" $ + runTestCfg2 testCfg testCfgVPrev testPlanAddressContactDeletedNoticePrevVersion describe "incognito" $ do it "connect incognito via invitation link" testConnectIncognitoInvitationLink it "connect incognito via contact address" testConnectIncognitoContactAddress @@ -1452,6 +1457,49 @@ testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile c bob #$> ("/_get chat #1 count=1", chat, [(0, "Full deletion: on")]) cath #$> ("/_get chat #1 count=1", chat, [(0, "Full deletion: on")]) +testBusinessAddressNotice :: HasCallStack => TestParams -> IO () +testBusinessAddressNotice = testChat3 businessProfile bobProfile cathProfile $ + \biz bob cath -> do + biz ##> "/ad" + cLink <- getContactLink biz True + biz ##> "/auto_accept on business" + biz <## "auto_accept on, business" + + connectBusinessCustomer biz bob cLink "bob" "Bob" + biz ##> "/rm #bob bob_1 notice={\"ttl\":86400,\"reason\":\"spam\"}" + biz <## "#bob: you removed bob_1 from the group (signed)" + bob <## "#biz: biz_1 removed you from the group (signed)" + bob <## "use /d #biz to delete the group" + bob ##> ("/_connect plan 1 " <> cLink) + bannedLine bob "contact address: " ", reason: spam" + bob ##> ("/_connect 1 " <> cLink) + bannedLine bob "connection link: " ", reason: spam" + + connectBusinessCustomer biz cath cLink "cath" "Catherine" + biz ##> "/_delete #2 full notice={\"reason\":\"other\"}" + biz <## "#cath: you deleted the group (signed)" + cath <## "#biz: biz_1 deleted the group (signed)" + cath <## "use /d #biz to delete the local copy of the group" + cath ##> ("/_connect plan 1 " <> cLink) + cath <## "contact address: banned permanently, reason: other" + cath ##> ("/c " <> cLink) + cath <## "contact address: banned permanently, reason: other" + cath ##> ("/_connect 1 " <> cLink) + cath <## "connection link: banned permanently, reason: other" + +connectBusinessCustomer :: HasCallStack => TestCC -> TestCC -> String -> String -> String -> IO () +connectBusinessCustomer biz customer cLink name fullName = do + customer ##> ("/c " <> cLink) + customer <## "connection request sent!" + biz <## ("#" <> name <> " (" <> fullName <> "): accepting business address request...") + concurrentlyN_ + [ biz <## ("#" <> name <> ": " <> name <> "_1 joined the group"), + customer + <### [ "#biz: joining the group...", + "#biz: you joined the group" + ] + ] + testPlanAddressOkKnown :: HasCallStack => TestParams -> IO () testPlanAddressOkKnown = testChat2 aliceProfile bobProfile $ @@ -1642,6 +1690,67 @@ testPlanAddressContactDeletedReconnected = bob <## "contact address: known contact alice_1" bob <## "use @alice_1 to send messages" +testPlanAddressContactDeletedNoticeSilent :: HasCallStack => TestParams -> IO () +testPlanAddressContactDeletedNoticeSilent = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + cLink <- connectViaAddress alice bob + + alice ##> "/_delete @2 full notify=off notice={\"ttl\":86400,\"reason\":\"spam\"}" + alice <## "bob: contact is deleted" + waitLinkNoticesCount bob 1 + + bob ##> ("/_connect plan 1 " <> cLink) + bannedLine bob "contact address: " ", reason: spam" + bob ##> ("/_connect plan 1 " <> linkAnotherSchema cLink) + bannedLine bob "contact address: " ", reason: spam" + bob ##> ("/c " <> cLink) + bannedLine bob "contact address: " ", reason: spam" + bob ##> ("/_connect 1 " <> cLink) + bannedLine bob "connection link: " ", reason: spam" + bob @@@ [("@alice", "hey")] + +testPlanAddressContactDeletedNoticeNotify :: HasCallStack => TestParams -> IO () +testPlanAddressContactDeletedNoticeNotify = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + cLink <- connectViaAddress alice bob + + alice ##> "/_delete @2 full notice={\"ttl\":86400}" + alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" + + bob ##> ("/_connect plan 1 " <> cLink) + bannedLine bob "contact address: " "" + linkNoticesCount bob `shouldReturn` 1 + +testPlanAddressContactDeletedNoticePrevVersion :: HasCallStack => TestCC -> TestCC -> IO () +testPlanAddressContactDeletedNoticePrevVersion alice bob = do + cLink <- connectViaAddress alice bob + + alice ##> "/_delete @2 full notify=off notice={\"ttl\":86400}" + alice <## "bob: contact is deleted" + threadDelay 500000 + + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" + linkNoticesCount bob `shouldReturn` 0 + +connectViaAddress :: HasCallStack => TestCC -> TestCC -> IO String +connectViaAddress alice bob = do + alice ##> "/ad" + cLink <- getContactLink alice True + bob ##> ("/c " <> cLink) + alice <#? bob + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request, you can send messages to contact" + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice <##> bob + pure cLink + testPlanAddressContactViaAddress :: HasCallStack => TestParams -> IO () testPlanAddressContactViaAddress = testChat2 aliceProfile bobProfile $ diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 9586957281..29d0c18e2e 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -680,6 +680,27 @@ createCCNoteFolder cc = withCCUser cc $ \user -> runExceptT (createNoteFolder db user) >>= either (fail . show) pure +linkNoticesCount :: TestCC -> IO Int +linkNoticesCount cc = + withCCTransaction cc $ \db -> + fromMaybe 0 <$> maybeFirstRow fromOnly (DB.query_ db "SELECT count(1) FROM link_notices") + +waitLinkNoticesCount :: HasCallStack => TestCC -> Int -> Expectation +waitLinkNoticesCount cc n = go (50 :: Int) + where + go attempts = do + count <- linkNoticesCount cc + if count == n || attempts <= 0 + then count `shouldBe` n + else threadDelay 100000 >> go (attempts - 1) + +bannedLine :: HasCallStack => TestCC -> String -> String -> Expectation +bannedLine cc prefix suffix = do + l <- getTermLine' (Just $ "banned: " <> prefix) cc + let banned = (prefix <> "banned until ") `isPrefixOf` l && suffix `isSuffixOf` l + unless banned $ print ("expected banned line: " <> prefix <> "... " <> suffix, ", got: " <> l) + banned `shouldBe` True + getProfilePictureByName :: TestCC -> String -> IO (Maybe String) getProfilePictureByName cc displayName = withTransaction (chatStore $ chatController cc) $ \db -> diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 554398520e..0384248c85 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -225,7 +225,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello"))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"9-20\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"9-21\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello"))) it "x.msg.new quote" $ "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -281,6 +281,9 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcQuote quotedMsg (MCReport "" RRSpam))) + it "x.msg.new report with illegal reason" $ + "{\"v\":\"9\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"reason\":\"illegal\",\"type\":\"report\"}}}" + ==# XMsgNew (mcSimple (MCReport "" RRContent)) it "x.msg.new forward with file" $ "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"},\"forward\":true}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward Nothing (MCText "hello")) {file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing, fileBadge = Nothing}}) @@ -342,13 +345,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-20\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-21\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-20\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-21\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" @@ -363,7 +366,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-20\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-21\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" @@ -376,13 +379,34 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XGrpMemConAll (MemberId "\1\2\3\4") it "x.grp.mem.del" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.del\",\"params\":{\"memberId\":\"AQIDBA==\"}}" - #==# XGrpMemDel (MemberId "\1\2\3\4") False Nothing + #==# XGrpMemDel (MemberId "\1\2\3\4") False Nothing Nothing + it "x.grp.mem.del with notice" $ + "{\"v\":\"9\",\"event\":\"x.grp.mem.del\",\"params\":{\"memberId\":\"AQIDBA==\",\"notice\":{\"ttl\":86400,\"reason\":\"spam\"}}}" + #==# XGrpMemDel (MemberId "\1\2\3\4") False Nothing (Just LinkNotice {ttl = Just 86400, reason = Just RRSpam}) + it "x.grp.mem.del with malformed notice" $ + "{\"v\":\"9\",\"event\":\"x.grp.mem.del\",\"params\":{\"memberId\":\"AQIDBA==\",\"notice\":{\"ttl\":\"day\"}}}" + ==# XGrpMemDel (MemberId "\1\2\3\4") False Nothing Nothing it "x.grp.leave" $ "{\"v\":\"9\",\"event\":\"x.grp.leave\",\"params\":{}}" ==# XGrpLeave it "x.grp.del" $ "{\"v\":\"9\",\"event\":\"x.grp.del\",\"params\":{}}" - ==# XGrpDel + ==# XGrpDel Nothing + it "x.grp.del with notice" $ + "{\"v\":\"9\",\"event\":\"x.grp.del\",\"params\":{\"notice\":{\"reason\":\"content\"}}}" + #==# XGrpDel (Just LinkNotice {ttl = Nothing, reason = Just RRContent}) + it "x.grp.del with malformed notice" $ + "{\"v\":\"9\",\"event\":\"x.grp.del\",\"params\":{\"notice\":1}}" + ==# XGrpDel Nothing + it "x.direct.del" $ + "{\"v\":\"9\",\"event\":\"x.direct.del\",\"params\":{}}" + #==# XDirectDel False Nothing + it "x.direct.del silent with notice" $ + "{\"v\":\"9\",\"event\":\"x.direct.del\",\"params\":{\"silent\":true,\"notice\":{\"ttl\":3600}}}" + #==# XDirectDel True (Just LinkNotice {ttl = Just 3600, reason = Nothing}) + it "x.direct.del with malformed notice" $ + "{\"v\":\"9\",\"event\":\"x.direct.del\",\"params\":{\"notice\":\"spam\"}}" + ==# XDirectDel False Nothing it "x.grp.direct.inv" $ "{\"v\":\"9\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XGrpDirectInv testConnReq (Just $ MCText "hello") Nothing