diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 8b84999577..90c95f4fc5 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -918,7 +918,7 @@ final class ChatModel: ObservableObject { } func decreaseGroupReportsCounter(_ chatId: ChatId, by: Int = 1) { - changeGroupReportsCounter(chatId, -1) + changeGroupReportsCounter(chatId, -by) } private func changeGroupReportsCounter(_ chatId: ChatId, _ by: Int = 0) { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 515b39284c..3418531d3d 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -516,6 +516,18 @@ func apiDeleteMemberChatItems(groupId: Int64, itemIds: [Int64]) async throws -> throw r } +func apiArchiveReceivedReports(groupId: Int64) async throws -> ChatResponse { + let r = await chatSendCmd(.apiArchiveReceivedReports(groupId: groupId), bgDelay: msgDelay) + if case .groupChatItemsDeleted = r { return r } + throw r +} + +func apiDeleteReceivedReports(groupId: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] { + let r = await chatSendCmd(.apiDeleteReceivedReports(groupId: groupId, itemIds: itemIds, mode: mode), bgDelay: msgDelay) + if case let .chatItemsDeleted(_, chatItemDeletions, _) = r { return chatItemDeletions } + throw r +} + func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode, String?) { let r = chatSendCmdSync(.apiGetNtfToken) switch r { @@ -2134,42 +2146,13 @@ func processReceivedMsg(_ res: ChatResponse) async { } else { m.removeChatItem(item.deletedChatItem.chatInfo, item.deletedChatItem.chatItem) } + if item.deletedChatItem.chatItem.isActiveReport { + m.decreaseGroupReportsCounter(item.deletedChatItem.chatInfo.id) + } } } case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member_): - if !active(user) { - do { - let users = try listUsers() - await MainActor.run { - m.users = users - } - } catch { - logger.error("Error loading users: \(error)") - } - return - } - let im = ItemsModel.shared - let cInfo = ChatInfo.group(groupInfo: groupInfo) - await MainActor.run { - m.decreaseGroupReportsCounter(cInfo.id, by: chatItemIDs.count) - } - var notFound = chatItemIDs.count - for ci in im.reversedChatItems { - if chatItemIDs.contains(ci.id) { - let deleted = if case let .groupRcv(groupMember) = ci.chatDir, let member_, groupMember.groupMemberId != member_.groupMemberId { - CIDeleted.moderated(deletedTs: Date.now, byGroupMember: member_) - } else { - CIDeleted.deleted(deletedTs: Date.now) - } - await MainActor.run { - var newItem = ci - newItem.meta.itemDeleted = deleted - _ = m.upsertChatItem(cInfo, newItem) - } - notFound -= 1 - if notFound == 0 { break } - } - } + await groupChatItemsDeleted(user, groupInfo, chatItemIDs, member_) case let .receivedGroupInvitation(user, groupInfo, _, _): if active(user) { await MainActor.run { @@ -2512,6 +2495,43 @@ func chatItemSimpleUpdate(_ user: any UserLike, _ aChatItem: AChatItem) async { } } +func groupChatItemsDeleted(_ user: UserRef, _ groupInfo: GroupInfo, _ chatItemIDs: Set, _ member_: GroupMember?) async { + let m = ChatModel.shared + if !active(user) { + do { + let users = try listUsers() + await MainActor.run { + m.users = users + } + } catch { + logger.error("Error loading users: \(error)") + } + return + } + let im = ItemsModel.shared + let cInfo = ChatInfo.group(groupInfo: groupInfo) + await MainActor.run { + m.decreaseGroupReportsCounter(cInfo.id, by: chatItemIDs.count) + } + var notFound = chatItemIDs.count + for ci in im.reversedChatItems { + if chatItemIDs.contains(ci.id) { + let deleted = if case let .groupRcv(groupMember) = ci.chatDir, let member_, groupMember.groupMemberId != member_.groupMemberId { + CIDeleted.moderated(deletedTs: Date.now, byGroupMember: member_) + } else { + CIDeleted.deleted(deletedTs: Date.now) + } + await MainActor.run { + var newItem = ci + newItem.meta.itemDeleted = deleted + _ = m.upsertChatItem(cInfo, newItem) + } + notFound -= 1 + if notFound == 0 { break } + } + } +} + func refreshCallInvitations() async throws { let m = ChatModel.shared let callInvitations = try await apiGetCallInvitations() diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 8c8e98c2f1..e83391e386 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -46,6 +46,7 @@ struct ChatView: View { @State private var forwardedChatItems: [ChatItem] = [] @State private var selectedChatItems: Set? = nil @State private var showDeleteSelectedMessages: Bool = false + @State private var showArchiveSelectedReports: Bool = false @State private var allowToDeleteSelectedMessagesForAll: Bool = false @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial @@ -100,6 +101,9 @@ struct ChatView: View { allowToDeleteSelectedMessagesForAll = forAll showDeleteSelectedMessages = true }, + archiveItems: { + showArchiveSelectedReports = true + }, moderateItems: { if case let .group(groupInfo) = chat.chatInfo { showModerateSelectedMessagesAlert(groupInfo) @@ -135,6 +139,20 @@ struct ChatView: View { } } } + .confirmationDialog(selectedChatItems?.count == 1 ? "Archive report?" : "Archive \((selectedChatItems?.count ?? 0)) reports?", isPresented: $showArchiveSelectedReports, titleVisibility: .visible) { + Button("For me", role: .destructive) { + if let selected = selectedChatItems { + archiveReports(chat.chatInfo, selected.sorted(), false, deletedSelectedMessages) + } + } + if case let ChatInfo.group(groupInfo) = chat.chatInfo, groupInfo.membership.memberActive { + Button("For all moderators", role: .destructive) { + if let selected = selectedChatItems { + archiveReports(chat.chatInfo, selected.sorted(), true, deletedSelectedMessages) + } + } + } + } .appSheet(item: $selectedMember) { member in Group { if case let .group(groupInfo) = chat.chatInfo { @@ -914,6 +932,8 @@ struct ChatView: View { @State private var showDeleteMessage = false @State private var deletingItems: [Int64] = [] @State private var showDeleteMessages = false + @State private var archivingReports: Set? = nil + @State private var showArchivingReports = false @State private var showChatItemInfoSheet: Bool = false @State private var chatItemInfo: ChatItemInfo? @State private var msgWidth: CGFloat = 0 @@ -1233,6 +1253,22 @@ struct ChatView: View { deleteMessages(chat, deletingItems, moderate: false) } } + .confirmationDialog(archivingReports?.count == 1 ? "Archive report?" : "Archive \(archivingReports?.count ?? 0) reports?", isPresented: $showArchivingReports, titleVisibility: .visible) { + Button("For me", role: .destructive) { + if let reports = self.archivingReports { + archiveReports(chat.chatInfo, reports.sorted(), false) + self.archivingReports = [] + } + } + if case let ChatInfo.group(groupInfo) = chat.chatInfo, groupInfo.membership.memberActive { + Button("For all moderators", role: .destructive) { + if let reports = self.archivingReports { + archiveReports(chat.chatInfo, reports.sorted(), true) + self.archivingReports = [] + } + } + } + } .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment) .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment) .sheet(isPresented: $showChatItemInfoSheet, onDismiss: { @@ -1694,18 +1730,9 @@ struct ChatView: View { } private func archiveReportButton(_ cItem: ChatItem) -> Button { - Button(role: .destructive) { - AlertManager.shared.showAlert( - Alert( - title: Text("Archive report?"), - message: Text("The report will be archived for you."), - primaryButton: .destructive(Text("Archive")) { - deletingItem = cItem - deleteMessage(.cidmInternalMark, moderate: false) - }, - secondaryButton: .cancel() - ) - ) + Button { + archivingReports = [cItem.id] + showArchivingReports = true } label: { Label("Archive report", systemImage: "archivebox") } @@ -1936,6 +1963,37 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe } } +func archiveReports(_ chatInfo: ChatInfo, _ itemIds: [Int64], _ forAll: Bool, _ onSuccess: @escaping () async -> Void = {}) { + if itemIds.count > 0 { + Task { + do { + let deleted = try await apiDeleteReceivedReports( + groupId: chatInfo.apiId, + itemIds: itemIds, + mode: forAll ? CIDeleteMode.cidmBroadcast : CIDeleteMode.cidmInternalMark + ) + + await MainActor.run { + for di in deleted { + if let toItem = di.toChatItem { + _ = ChatModel.shared.upsertChatItem(chatInfo, toItem.chatItem) + } else { + ChatModel.shared.removeChatItem(chatInfo, di.deletedChatItem.chatItem) + } + let deletedItem = di.deletedChatItem.chatItem + if deletedItem.isActiveReport { + ChatModel.shared.decreaseGroupReportsCounter(chatInfo.id) + } + } + } + await onSuccess() + } catch { + logger.error("ChatView.archiveReports error: \(error.localizedDescription)") + } + } + } +} + private func buildTheme() -> AppTheme { if let cId = ChatModel.shared.chatId, let chat = ChatModel.shared.getChat(cId) { let perChatTheme = if case let .direct(contact) = chat.chatInfo { diff --git a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift index 81498ee497..85d6b279c5 100644 --- a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift +++ b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift @@ -30,12 +30,15 @@ struct SelectedItemsBottomToolbar: View { var chatInfo: ChatInfo // Bool - delete for everyone is possible var deleteItems: (Bool) -> Void + var archiveItems: () -> Void var moderateItems: () -> Void //var shareItems: () -> Void var forwardItems: () -> Void @State var deleteEnabled: Bool = false @State var deleteForEveryoneEnabled: Bool = false + @State var canArchiveReports: Bool = false + @State var canModerate: Bool = false @State var moderateEnabled: Bool = false @@ -50,7 +53,11 @@ struct SelectedItemsBottomToolbar: View { HStack(alignment: .center) { Button { - deleteItems(deleteForEveryoneEnabled) + if canArchiveReports { + archiveItems() + } else { + deleteItems(deleteForEveryoneEnabled) + } } label: { Image(systemName: "trash") .resizable() @@ -109,19 +116,25 @@ struct SelectedItemsBottomToolbar: View { deleteCountProhibited = count == 0 || count > 200 forwardCountProhibited = count == 0 || count > 20 canModerate = possibleToModerate(chatInfo) + let groupInfo: GroupInfo? = if case let ChatInfo.group(groupInfo: info) = chatInfo { + info + } else { + nil + } if let selected = selectedItems { let me: Bool let onlyOwnGroupItems: Bool - (deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, forwardEnabled, selectedChatItems) = chatItems.reduce((true, true, true, true, true, [])) { (r, ci) in + (deleteEnabled, deleteForEveryoneEnabled, canArchiveReports, me, onlyOwnGroupItems, forwardEnabled, selectedChatItems) = chatItems.reduce((true, true, true, true, true, true, [])) { (r, ci) in if selected.contains(ci.id) { - var (de, dee, me, onlyOwnGroupItems, fe, sel) = r + var (de, dee, ar, me, onlyOwnGroupItems, fe, sel) = r de = de && ci.canBeDeletedForSelf dee = dee && ci.meta.deletable && !ci.localNote && !ci.isReport + ar = ar && ci.isActiveReport && ci.chatDir != .groupSnd && groupInfo != nil && groupInfo!.membership.memberRole >= .moderator onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd && !ci.isReport me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil && !ci.isReport fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy && !ci.isReport sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list - return (de, dee, me, onlyOwnGroupItems, fe, sel) + return (de, dee, ar, me, onlyOwnGroupItems, fe, sel) } else { return r } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 39268d4727..e3aad890bb 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -211,23 +211,32 @@ struct ChatListNavLink: View { } .swipeActions(edge: .trailing, allowsFullSwipe: true) { tagChatButton(chat) + let showReportsButton = chat.chatStats.reportsCount > 0 && groupInfo.membership.memberRole >= .moderator let showClearButton = !chat.chatItems.isEmpty let showDeleteGroup = groupInfo.canDelete let showLeaveGroup = groupInfo.membership.memberCurrent - let totalNumberOfButtons = 1 + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0) + let totalNumberOfButtons = 1 + (showReportsButton ? 1 : 0) + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0) - if showClearButton, totalNumberOfButtons <= 3 { + if showClearButton && totalNumberOfButtons <= 3 { clearChatButton() } - if (showLeaveGroup) { + + if showReportsButton && totalNumberOfButtons <= 3 { + archiveAllReportsButton() + } + + if showLeaveGroup { leaveGroupChatButton(groupInfo) } - - if showDeleteGroup { - if totalNumberOfButtons <= 3 { + + if showDeleteGroup && totalNumberOfButtons <= 3 { + deleteGroupChatButton(groupInfo) + } else if totalNumberOfButtons > 3 { + if showDeleteGroup && !groupInfo.membership.memberActive { deleteGroupChatButton(groupInfo) + moreOptionsButton(false, chat, groupInfo) } else { - moreOptionsButton(chat, groupInfo) + moreOptionsButton(true, chat, groupInfo) } } } @@ -313,6 +322,14 @@ struct ChatListNavLink: View { } } + private func archiveAllReportsButton() -> some View { + Button { + AlertManager.shared.showAlert(archiveAllReportsAlert()) + } label: { + SwipeLabel(NSLocalizedString("Archive reports", comment: "swipe action"), systemImage: "archivebox", inverted: oneHandUI) + } + } + private func clearChatButton() -> some View { Button { AlertManager.shared.showAlert(clearChatAlert()) @@ -354,15 +371,20 @@ struct ChatListNavLink: View { ) } - private func moreOptionsButton(_ chat: Chat, _ groupInfo: GroupInfo?) -> some View { + private func moreOptionsButton(_ canShowGroupDelete: Bool, _ chat: Chat, _ groupInfo: GroupInfo?) -> some View { Button { - var buttons: [Alert.Button] = [ - .default(Text("Clear")) { - AlertManager.shared.showAlert(clearChatAlert()) - } - ] - - if let gi = groupInfo, gi.canDelete { + var buttons: [Alert.Button] = [] + buttons.append(.default(Text("Clear")) { + AlertManager.shared.showAlert(clearChatAlert()) + }) + + if let groupInfo, chat.chatStats.reportsCount > 0 && groupInfo.membership.memberRole >= .moderator && groupInfo.ready { + buttons.append(.default(Text("Archive reports")) { + AlertManager.shared.showAlert(archiveAllReportsAlert()) + }) + } + + if canShowGroupDelete, let gi = groupInfo, gi.canDelete { buttons.append(.destructive(Text("Delete")) { AlertManager.shared.showAlert(deleteGroupAlert(gi)) }) @@ -372,7 +394,7 @@ struct ChatListNavLink: View { actionSheet = SomeActionSheet( actionSheet: ActionSheet( - title: Text("Clear or delete group?"), + title: canShowGroupDelete ? Text("Clear or delete group?") : Text("Clear group?"), buttons: buttons ), id: "other options" @@ -490,6 +512,27 @@ struct ChatListNavLink: View { ) } + private func archiveAllReportsAlert() -> Alert { + Alert( + title: Text("Archive all reports?"), + message: Text("All reports will be archived for you."), + primaryButton: .destructive(Text("Archive")) { + Task { await archiveAllReportsForMe(chat.chatInfo.apiId) } + }, + secondaryButton: .cancel() + ) + } + + private func archiveAllReportsForMe(_ apiId: Int64) async { + do { + if case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member) = try await apiArchiveReceivedReports(groupId: apiId) { + await groupChatItemsDeleted(user, groupInfo, chatItemIDs, member) + } + } catch { + logger.error("archiveAllReportsForMe error: \(responseError(error))") + } + } + private func clearChatAlert() -> Alert { Alert( title: Text("Clear conversation?"), diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index c553132dd2..ba94463d61 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -790,7 +790,12 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotificationData)? cItem = autoReceiveFile(file) ?? cItem } let ntf: NSENotificationData = (cInfo.ntfsEnabled(chatItem: cItem) && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf - return (chatItem.chatId, ntf) + let chatIdOrMemberId = if case let .groupRcv(groupMember) = chatItem.chatItem.chatDir { + groupMember.id + } else { + chatItem.chatInfo.id + } + return (chatIdOrMemberId, ntf) } else { return nil } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 4c26b0cf00..bd022165f0 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -55,6 +55,8 @@ public enum ChatCommand { case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool) case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64]) + case apiArchiveReceivedReports(groupId: Int64) + case apiDeleteReceivedReports(groupId: Int64, itemIds: [Int64], mode: CIDeleteMode) case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) case apiGetReactionMembers(userId: Int64, groupId: Int64, itemId: Int64, reaction: MsgReaction) case apiPlanForwardChatItems(toChatType: ChatType, toChatId: Int64, itemIds: [Int64]) @@ -230,6 +232,8 @@ public enum ChatCommand { case let .apiUpdateChatItem(type, id, itemId, um, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(um.cmdString)" case let .apiDeleteChatItem(type, id, itemIds, mode): return "/_delete item \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)" case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))" + case let .apiArchiveReceivedReports(groupId): return "/_archive reports #\(groupId)" + case let .apiDeleteReceivedReports(groupId, itemIds, mode): return "/_delete reports #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)" case let .apiChatItemReaction(type, id, itemId, add, reaction): return "/_reaction \(ref(type, id)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))" case let .apiGetReactionMembers(userId, groupId, itemId, reaction): return "/_reaction members \(userId) #\(groupId) \(itemId) \(encodeJSON(reaction))" case let .apiPlanForwardChatItems(type, id, itemIds): return "/_forward plan \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ","))" @@ -404,6 +408,8 @@ public enum ChatCommand { case .apiDeleteChatItem: return "apiDeleteChatItem" case .apiConnectContactViaAddress: return "apiConnectContactViaAddress" case .apiDeleteMemberChatItem: return "apiDeleteMemberChatItem" + case .apiArchiveReceivedReports: return "apiArchiveReceivedReports" + case .apiDeleteReceivedReports: return "apiDeleteReceivedReports" case .apiChatItemReaction: return "apiChatItemReaction" case .apiGetReactionMembers: return "apiGetReactionMembers" case .apiPlanForwardChatItems: return "apiPlanForwardChatItems" diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index e974c2ca86..58bbfdbfd8 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2435,13 +2435,6 @@ public struct ChatItemDeletion: Decodable, Hashable { public struct AChatItem: Decodable, Hashable { public var chatInfo: ChatInfo public var chatItem: ChatItem - - public var chatId: String { - if case let .groupRcv(groupMember) = chatItem.chatDir { - return groupMember.id - } - return chatInfo.id - } } public struct CIMentionMember: Decodable, Hashable { diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index a922e3a816..97cc633115 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -203,6 +203,11 @@ func hideSecrets(_ cItem: ChatItem) -> String { } return res } else { - return cItem.text + let mc = cItem.content.msgContent + if case let .report(text, reason) = mc { + return NSLocalizedString("Report: \(text.isEmpty ? reason.text : text)", comment: "report in notification") + } else { + return cItem.text + } } } 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 3d64fda251..51c27b21de 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 @@ -851,8 +851,8 @@ object ChatModel { changeGroupReportsCounter(rhId, chatId, 1) } - fun decreaseGroupReportsCounter(rhId: Long?, chatId: ChatId) { - changeGroupReportsCounter(rhId, chatId, -1) + fun decreaseGroupReportsCounter(rhId: Long?, chatId: ChatId, by: Int = 1) { + changeGroupReportsCounter(rhId, chatId, -by) } private fun changeGroupReportsCounter(rhId: Long?, chatId: ChatId, by: Int = 0) { 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 abb449f0cc..7205bcc89b 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 @@ -1071,6 +1071,20 @@ object ChatController { return null } + suspend fun apiArchiveReceivedReports(rh: Long?, groupId: Long): CR.GroupChatItemsDeleted? { + val r = sendCmd(rh, CC.ApiArchiveReceivedReports(groupId)) + if (r is CR.GroupChatItemsDeleted) return r + Log.e(TAG, "apiArchiveReceivedReports bad response: ${r.responseType} ${r.details}") + return null + } + + suspend fun apiDeleteReceivedReports(rh: Long?, groupId: Long, itemIds: List, mode: CIDeleteMode): List? { + val r = sendCmd(rh, CC.ApiDeleteReceivedReports(groupId, itemIds, mode)) + if (r is CR.ChatItemsDeleted) return r.chatItemDeletions + Log.e(TAG, "apiDeleteReceivedReports bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun testProtoServer(rh: Long?, server: String): ProtocolTestFailure? { val userId = currentUserId("testProtoServer") val r = sendCmd(rh, CC.APITestProtoServer(userId, server)) @@ -2606,6 +2620,9 @@ object ChatController { } else { upsertChatItem(rhId, cInfo, toChatItem.chatItem) } + if (cItem.isActiveReport) { + decreaseGroupReportsCounter(rhId, cInfo.id) + } } withReportsChatsIfOpen { if (cItem.isReport) { @@ -2619,54 +2636,7 @@ object ChatController { } } is CR.GroupChatItemsDeleted -> { - if (!active(r.user)) { - val users = chatController.listUsers(rhId) - chatModel.users.clear() - chatModel.users.addAll(users) - return - } - val cInfo = ChatInfo.Group(r.groupInfo) - withChats { - r.chatItemIDs.forEach { itemId -> - decreaseGroupReportsCounter(rhId, cInfo.id) - val cItem = chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach - if (chatModel.chatId.value != null) { - // Stop voice playback only inside a chat, allow to play in a chat list - AudioPlayer.stop(cItem) - } - val isLastChatItem = getChat(cInfo.id)?.chatItems?.lastOrNull()?.id == cItem.id - if (isLastChatItem && ntfManager.hasNotificationsForChat(cInfo.id)) { - ntfManager.cancelNotificationsForChat(cInfo.id) - ntfManager.displayNotification( - r.user, - cInfo.id, - cInfo.displayName, - generalGetString(MR.strings.marked_deleted_description) - ) - } - val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember?.groupMemberId != r.member_.groupMemberId) { - CIDeleted.Moderated(Clock.System.now(), r.member_) - } else { - CIDeleted.Deleted(Clock.System.now()) - } - upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) - } - } - withReportsChatsIfOpen { - r.chatItemIDs.forEach { itemId -> - val cItem = chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach - if (chatModel.chatId.value != null) { - // Stop voice playback only inside a chat, allow to play in a chat list - AudioPlayer.stop(cItem) - } - val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember?.groupMemberId != r.member_.groupMemberId) { - CIDeleted.Moderated(Clock.System.now(), r.member_) - } else { - CIDeleted.Deleted(Clock.System.now()) - } - upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) - } - } + groupChatItemsDeleted(rhId, r) } is CR.ReceivedGroupInvitation -> { if (active(r.user)) { @@ -3150,6 +3120,57 @@ object ChatController { } } + suspend fun groupChatItemsDeleted(rhId: Long?, r: CR.GroupChatItemsDeleted) { + if (!activeUser(rhId, r.user)) { + val users = chatController.listUsers(rhId) + chatModel.users.clear() + chatModel.users.addAll(users) + return + } + val cInfo = ChatInfo.Group(r.groupInfo) + withChats { + r.chatItemIDs.forEach { itemId -> + decreaseGroupReportsCounter(rhId, cInfo.id) + val cItem = chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach + if (chatModel.chatId.value != null) { + // Stop voice playback only inside a chat, allow to play in a chat list + AudioPlayer.stop(cItem) + } + val isLastChatItem = getChat(cInfo.id)?.chatItems?.lastOrNull()?.id == cItem.id + if (isLastChatItem && ntfManager.hasNotificationsForChat(cInfo.id)) { + ntfManager.cancelNotificationsForChat(cInfo.id) + ntfManager.displayNotification( + r.user, + cInfo.id, + cInfo.displayName, + generalGetString(MR.strings.marked_deleted_description) + ) + } + val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember?.groupMemberId != r.member_.groupMemberId) { + CIDeleted.Moderated(Clock.System.now(), r.member_) + } else { + CIDeleted.Deleted(Clock.System.now()) + } + upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) + } + } + withReportsChatsIfOpen { + r.chatItemIDs.forEach { itemId -> + val cItem = chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach + if (chatModel.chatId.value != null) { + // Stop voice playback only inside a chat, allow to play in a chat list + AudioPlayer.stop(cItem) + } + val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember?.groupMemberId != r.member_.groupMemberId) { + CIDeleted.Moderated(Clock.System.now(), r.member_) + } else { + CIDeleted.Deleted(Clock.System.now()) + } + upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) + } + } + } + private suspend fun chatItemUpdateNotify(rh: Long?, user: UserLike, aChatItem: AChatItem) { val cInfo = aChatItem.chatInfo val cItem = aChatItem.chatItem @@ -3375,6 +3396,8 @@ sealed class CC { class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val updatedMessage: UpdatedMessage, val live: Boolean): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemIds: List, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val itemIds: List): CC() + class ApiArchiveReceivedReports(val groupId: Long): CC() + class ApiDeleteReceivedReports(val groupId: Long, val itemIds: List, val mode: CIDeleteMode): CC() class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() class ApiGetReactionMembers(val userId: Long, val groupId: Long, val itemId: Long, val reaction: MsgReaction): CC() class ApiPlanForwardChatItems(val fromChatType: ChatType, val fromChatId: Long, val chatItemIds: List): CC() @@ -3553,6 +3576,8 @@ sealed class CC { is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${updatedMessage.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} ${itemIds.joinToString(",")} ${mode.deleteMode}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId ${itemIds.joinToString(",")}" + is ApiArchiveReceivedReports -> "/_archive reports #$groupId" + is ApiDeleteReceivedReports -> "/_delete reports #$groupId ${itemIds.joinToString(",")} ${mode.deleteMode}" is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}" is ApiGetReactionMembers -> "/_reaction members $userId #$groupId $itemId ${json.encodeToString(reaction)}" is ApiForwardChatItems -> { @@ -3719,6 +3744,8 @@ sealed class CC { is ApiUpdateChatItem -> "apiUpdateChatItem" is ApiDeleteChatItem -> "apiDeleteChatItem" is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem" + is ApiArchiveReceivedReports -> "apiArchiveReceivedReports" + is ApiDeleteReceivedReports -> "apiDeleteReceivedReports" is ApiChatItemReaction -> "apiChatItemReaction" is ApiGetReactionMembers -> "apiGetReactionMembers" is ApiForwardChatItems -> "apiForwardChatItems" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index 43ea0a499e..5efd3747a3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -134,7 +134,12 @@ abstract class NtfManager { } res } else { - cItem.text + val mc = cItem.content.msgContent + if (mc is MsgContent.MCReport) { + generalGetString(MR.strings.notification_group_report).format(cItem.text.ifEmpty { mc.reason.text }) + } else { + cItem.text + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 51dc568e24..8b94f36d75 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -196,6 +196,7 @@ fun ChatView( ) } }, + archiveItems = { archiveItems(chatRh, chatInfo, selectedChatItems) }, moderateItems = { if (chatInfo is ChatInfo.Group) { val itemIds = selectedChatItems.value @@ -397,6 +398,7 @@ fun ChatView( } }, deleteMessages = { itemIds -> deleteMessages(chatRh, chatInfo, itemIds, false, moderate = false) }, + archiveReports = { itemIds, forAll -> archiveReports(chatRh, chatInfo, itemIds, forAll) }, receiveFile = { fileId -> withBGApi { chatModel.controller.receiveFile(chatRh, user, fileId) } }, @@ -673,6 +675,7 @@ fun ChatLayout( loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, + archiveReports: (List, Boolean) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, joinGroup: (Long, () -> Unit) -> Unit, @@ -745,7 +748,7 @@ fun ChatLayout( }) { ChatItemsList( remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue, - useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, + useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, archiveReports, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, setReaction, showItemDetails, markItemsRead, markChatRead, remember { { onComposed(it) } }, developerTools, showViaProxy, @@ -792,6 +795,7 @@ fun ChatLayout( }) } }, + archiveItems = { archiveItems(remoteHostId, chatInfo, selectedChatItems) }, moderateItems = {}, forwardItems = {} ) @@ -1138,6 +1142,7 @@ fun BoxScope.ChatItemsList( loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, + archiveReports: (List, Boolean) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, joinGroup: (Long, () -> Unit) -> Unit, @@ -1291,7 +1296,7 @@ fun BoxScope.ChatItemsList( highlightedItems.value = setOf() } } - ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) + ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) } } @@ -2368,6 +2373,59 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List, forAll: Boolean, onSuccess: () -> Unit = {}) { + if (itemIds.isNotEmpty()) { + withBGApi { + val deleted = chatModel.controller.apiDeleteReceivedReports( + chatRh, + groupId = chatInfo.apiId, + itemIds = itemIds, + mode = if (forAll) CIDeleteMode.cidmBroadcast else CIDeleteMode.cidmInternalMark + ) + if (deleted != null) { + withChats { + for (di in deleted) { + val toChatItem = di.toChatItem?.chatItem + if (toChatItem != null) { + upsertChatItem(chatRh, chatInfo, toChatItem) + } else { + removeChatItem(chatRh, chatInfo, di.deletedChatItem.chatItem) + } + val deletedItem = di.deletedChatItem.chatItem + if (deletedItem.isActiveReport) { + decreaseGroupReportsCounter(chatRh, chatInfo.id) + } + } + } + withReportsChatsIfOpen { + for (di in deleted) { + if (di.deletedChatItem.chatItem.isReport) { + val toChatItem = di.toChatItem?.chatItem + if (toChatItem != null) { + upsertChatItem(chatRh, chatInfo, toChatItem) + } else { + removeChatItem(chatRh, chatInfo, di.deletedChatItem.chatItem) + } + } + } + } + onSuccess() + } + } + } +} + +private fun archiveItems(rhId: Long?, chatInfo: ChatInfo, selectedChatItems: MutableState?>) { + val itemIds = selectedChatItems.value + if (itemIds != null) { + showArchiveReportsAlert(itemIds.sorted(), chatInfo is ChatInfo.Group && chatInfo.groupInfo.membership.memberActive, archiveReports = { ids, forAll -> + archiveReports(rhId, chatInfo, ids, forAll) { + selectedChatItems.value = null + } + }) + } +} + private fun markUnreadChatAsRead(chatId: String) { val chat = chatModel.chats.value.firstOrNull { it.id == chatId } if (chat?.chatStats?.unreadChat != true) return @@ -2716,6 +2774,7 @@ fun PreviewChatLayout() { loadMessages = { _, _, _ -> }, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, + archiveReports = { _, _ -> }, receiveFile = { _ -> }, cancelFile = {}, joinGroup = { _, _ -> }, @@ -2791,6 +2850,7 @@ fun PreviewGroupChatLayout() { loadMessages = { _, _, _ -> }, deleteMessage = { _, _ -> }, deleteMessages = {}, + archiveReports = { _, _ -> }, receiveFile = { _ -> }, cancelFile = {}, joinGroup = { _, _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt index 457f8f1e40..85e3f3936e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt @@ -53,11 +53,13 @@ fun SelectedItemsBottomToolbar( contentTag: MsgContentTag?, selectedChatItems: MutableState?>, deleteItems: (Boolean) -> Unit, // Boolean - delete for everyone is possible + archiveItems: () -> Unit, moderateItems: () -> Unit, forwardItems: () -> Unit, ) { val deleteEnabled = remember { mutableStateOf(false) } val deleteForEveryoneEnabled = remember { mutableStateOf(false) } + val canArchiveReports = remember { mutableStateOf(false) } val canModerate = remember { mutableStateOf(false) } val moderateEnabled = remember { mutableStateOf(false) } val forwardEnabled = remember { mutableStateOf(false) } @@ -80,7 +82,7 @@ fun SelectedItemsBottomToolbar( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - IconButton({ deleteItems(deleteForEveryoneEnabled.value) }, enabled = deleteEnabled.value && !deleteCountProhibited.value) { + IconButton({ if (canArchiveReports.value) archiveItems() else deleteItems(deleteForEveryoneEnabled.value) }, enabled = deleteEnabled.value && !deleteCountProhibited.value) { Icon( painterResource(MR.images.ic_delete), null, @@ -111,7 +113,7 @@ fun SelectedItemsBottomToolbar( } val chatItems = remember { derivedStateOf { chatModel.chatItemsForContent(contentTag).value } } LaunchedEffect(chatInfo, chatItems.value, selectedChatItems.value) { - recheckItems(chatInfo, chatItems.value, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited) + recheckItems(chatInfo, chatItems.value, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canArchiveReports, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited) } } @@ -120,6 +122,7 @@ private fun recheckItems(chatInfo: ChatInfo, selectedChatItems: MutableState?>, deleteEnabled: MutableState, deleteForEveryoneEnabled: MutableState, + canArchiveReports: MutableState, canModerate: MutableState, moderateEnabled: MutableState, forwardEnabled: MutableState, @@ -133,6 +136,7 @@ private fun recheckItems(chatInfo: ChatInfo, val selected = selectedChatItems.value ?: return var rDeleteEnabled = true var rDeleteForEveryoneEnabled = true + var rCanArchiveReports = true var rModerateEnabled = true var rOnlyOwnGroupItems = true var rForwardEnabled = true @@ -141,6 +145,7 @@ private fun recheckItems(chatInfo: ChatInfo, if (selected.contains(ci.id)) { rDeleteEnabled = rDeleteEnabled && ci.canBeDeletedForSelf rDeleteForEveryoneEnabled = rDeleteForEveryoneEnabled && ci.meta.deletable && !ci.localNote && !ci.isReport + rCanArchiveReports = rCanArchiveReports && ci.isActiveReport && ci.chatDir !is CIDirection.GroupSnd && chatInfo is ChatInfo.Group && chatInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator rOnlyOwnGroupItems = rOnlyOwnGroupItems && ci.chatDir is CIDirection.GroupSnd && !ci.isReport rModerateEnabled = rModerateEnabled && ci.content.msgContent != null && ci.memberToModerate(chatInfo) != null && !ci.isReport rForwardEnabled = rForwardEnabled && ci.content.msgContent != null && ci.meta.itemDeleted == null && !ci.isLiveDummy && !ci.isReport @@ -150,6 +155,7 @@ private fun recheckItems(chatInfo: ChatInfo, rModerateEnabled = rModerateEnabled && !rOnlyOwnGroupItems deleteEnabled.value = rDeleteEnabled deleteForEveryoneEnabled.value = rDeleteForEveryoneEnabled + canArchiveReports.value = rCanArchiveReports moderateEnabled.value = rModerateEnabled forwardEnabled.value = rForwardEnabled selectedChatItems.value = rSelectedChatItems diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 10e902f24b..8c053ad1d2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -77,6 +77,7 @@ fun ChatItemView( selectChatItem: () -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, + archiveReports: (List, Boolean) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, joinGroup: (Long, () -> Unit) -> Unit, @@ -301,7 +302,7 @@ fun ChatItemView( cItem.isReport && cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group -> { DefaultDropdownMenu(showMenu) { if (cItem.chatDir !is CIDirection.GroupSnd && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { - ArchiveReportItemAction(cItem, showMenu, deleteMessage) + ArchiveReportItemAction(cItem.id, cInfo.groupInfo.membership.memberActive, showMenu, archiveReports) } DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) Divider() @@ -914,23 +915,53 @@ private fun ReportItemAction( } @Composable -private fun ArchiveReportItemAction(cItem: ChatItem, showMenu: MutableState, deleteMessage: (Long, CIDeleteMode) -> Unit) { +private fun ArchiveReportItemAction(id: Long, allowForAll: Boolean, showMenu: MutableState, archiveReports: (List, Boolean) -> Unit) { ItemAction( stringResource(MR.strings.archive_report), painterResource(MR.images.ic_inventory_2), onClick = { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.report_archive_alert_title), - text = generalGetString(MR.strings.report_archive_alert_desc), - onConfirm = { - deleteMessage(cItem.id, CIDeleteMode.cidmInternalMark) - }, - destructive = true, - confirmText = generalGetString(MR.strings.archive_verb), - ) + showArchiveReportsAlert(listOf(id), allowForAll, archiveReports) showMenu.value = false + } + ) +} + +fun showArchiveReportsAlert(ids: List, allowForAll: Boolean, archiveReports: (List, Boolean) -> Unit) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = if (ids.size == 1) { + generalGetString(MR.strings.report_archive_alert_title) + } else { + generalGetString(MR.strings.report_archive_alert_title_nth).format(ids.size) }, - color = Color.Red + text = null, + buttons = { + // Archive for me + SectionItemView({ + AlertManager.shared.hideAlert() + archiveReports(ids, false) + }) { + Text( + generalGetString(MR.strings.report_archive_for_me), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = MaterialTheme.colors.error + ) + } + if (allowForAll) { + // Archive for all moderators + SectionItemView({ + AlertManager.shared.hideAlert() + archiveReports(ids, true) + }) { + Text( + stringResource(MR.strings.report_archive_for_all_moderators), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = MaterialTheme.colors.error + ) + } + } + } ) } @@ -1310,6 +1341,7 @@ fun PreviewChatItemView( selectChatItem = {}, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, + archiveReports = { _, _ -> }, receiveFile = { _ -> }, cancelFile = {}, joinGroup = { _, _ -> }, @@ -1356,6 +1388,7 @@ fun PreviewChatItemViewDeletedContent() { selectChatItem = {}, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, + archiveReports = { _, _ -> }, receiveFile = { _ -> }, cancelFile = {}, joinGroup = { _, _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 8e01c2b4bb..4cdd4b5c05 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.platform.* @@ -299,6 +300,11 @@ fun GroupMenuItems( ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu) ToggleNotificationsChatAction(chat, chatModel, groupInfo.chatSettings.enableNtfs.nextMode(true), showMenu) TagListAction(chat, showMenu) + if (chat.chatStats.reportsCount > 0 && groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + ArchiveAllReportsItemAction(showMenu) { + archiveAllReportsForMe(chat.remoteHostId, chat.chatInfo.apiId) + } + } ClearChatAction(chat, showMenu) if (groupInfo.membership.memberCurrent) { LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) @@ -563,6 +569,18 @@ private fun InvalidDataView() { } } +@Composable +private fun ArchiveAllReportsItemAction(showMenu: MutableState, archiveReports: () -> Unit) { + ItemAction( + stringResource(MR.strings.archive_reports), + painterResource(MR.images.ic_inventory_2), + onClick = { + showArchiveAllReportsForMeAlert(archiveReports) + showMenu.value = false + } + ) +} + fun markChatRead(c: Chat) { var chat = c withApi { @@ -886,6 +904,25 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch } } +private fun showArchiveAllReportsForMeAlert(archiveReports: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.report_archive_alert_title_all), + text = generalGetString(MR.strings.report_archive_alert_desc_all), + onConfirm = archiveReports, + destructive = true, + confirmText = generalGetString(MR.strings.archive_verb), + ) +} + +private fun archiveAllReportsForMe(chatRh: Long?, apiId: Long) { + withBGApi { + val r = chatModel.controller.apiArchiveReceivedReports(chatRh, apiId) + if (r != null) { + controller.groupChatItemsDeleted(chatRh, r) + } + } +} + @Composable expect fun ChatListNavLinkLayout( chatLinkPreview: @Composable () -> Unit, 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 4798c9df56..eb4012255c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -312,7 +312,12 @@ This message was deleted or not received yet. Report reason? Archive report? + Archive %d reports? + Archive all reports? The report will be archived for you. + All reports will be archived for you. + For me + For all moderators Error: %1$s @@ -341,6 +346,7 @@ Search Archive Archive report + Archive reports Delete report Sent message Received message @@ -447,6 +453,7 @@ Businesses Notes Reports + Report: %s All Add list 1 report diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 4aed1b053d..4c5beb9cee 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3740,8 +3740,8 @@ chatCommandP = "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <*> (" json" *> jsonP <|> " text " *> updatedMessagesTextP)), "/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <*> _strP), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <*> _strP), - "/_archive reports " *> (APIArchiveReceivedReports <$> A.decimal), - "/_delete reports " *> (APIDeleteReceivedReports <$> A.decimal <*> _strP <*> _strP), + "/_archive reports #" *> (APIArchiveReceivedReports <$> A.decimal), + "/_delete reports #" *> (APIDeleteReceivedReports <$> A.decimal <*> _strP <*> _strP), "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP), "/_reaction members " *> (APIGetReactionMembers <$> A.decimal <* " #" <*> A.decimal <* A.space <*> A.decimal <* A.space <*> jsonP), "/_forward plan " *> (APIPlanForwardChatItems <$> chatRefP <*> _strP), diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 04976ad5cd..ca74e5e1bc 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -6031,12 +6031,12 @@ testGroupMemberReports = bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content"), (0, "report spam")]) cath #$> ("/_get chat #1 content=report count=100", chat, []) dan #$> ("/_get chat #1 content=report count=100", chat, [(1, "report content"), (1, "report spam")]) - alice ##> "/_archive reports 1" + alice ##> "/_archive reports #1" alice <## "#jokes: 2 messages deleted by user" (bob ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by you]"), (0, "report spam [marked deleted by you]")]) bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content"), (0, "report spam")]) - bob ##> "/_archive reports 1" + bob ##> "/_archive reports #1" bob <## "#jokes: 2 messages deleted by user" bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by you]"), (0, "report spam [marked deleted by you]")]) -- delete reports for all admins @@ -6063,7 +6063,7 @@ testGroupMemberReports = ] alice ##> "/last_item_id" i :: ChatItemId <- read <$> getTermLine alice - alice ##> ("/_delete reports 1 " <> show i <> " broadcast") + alice ##> ("/_delete reports #1 " <> show i <> " broadcast") alice <## "message marked deleted by you" bob <# "#jokes dan> [marked deleted by alice] report content" alice #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by you]")])