diff --git a/Sources/ChatFeature/Controllers/GroupChatController.swift b/Sources/ChatFeature/Controllers/GroupChatController.swift index ae732094cc8cc4304781ad90406b1d151722567f..9d5e9a67a577cc535b3b350f4ff7c1e0008dbd5a 100644 --- a/Sources/ChatFeature/Controllers/GroupChatController.swift +++ b/Sources/ChatFeature/Controllers/GroupChatController.swift @@ -1,3 +1,4 @@ +import HUD import UIKit import Theme import Models @@ -6,8 +7,10 @@ import Combine import XXModels import Voxophone import ChatLayout +import Integration import DrawerFeature import DifferenceKit +import ReportingFeature import ChatInputFeature import DependencyInjection @@ -19,7 +22,11 @@ typealias OutgoingFailedGroupTextCell = CollectionCell<FlexibleSpace, StackMessa typealias OutgoingFailedGroupReplyCell = CollectionCell<FlexibleSpace, ReplyStackMessageView> public final class GroupChatController: UIViewController { + @Dependency private var hud: HUD + @Dependency private var session: SessionType @Dependency private var coordinator: ChatCoordinating + @Dependency private var makeReportDrawer: MakeReportDrawer + @Dependency private var makeAppScreenshot: MakeAppScreenshot @Dependency private var statusBarController: StatusBarStyleControlling private let members: MembersController @@ -176,6 +183,17 @@ public final class GroupChatController: UIViewController { } }.store(in: &cancellables) + viewModel.hudPublisher + .receive(on: DispatchQueue.main) + .sink { [hud] in hud.update(with: $0) } + .store(in: &cancellables) + + viewModel.reportPopupPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] contact in + presentReportDrawer(contact) + }.store(in: &cancellables) + viewModel.messages .receive(on: DispatchQueue.main) .sink { [unowned self] sections in @@ -229,6 +247,19 @@ public final class GroupChatController: UIViewController { coordinator.toMembersList(members, from: self) } + private func presentReportDrawer(_ contact: Contact) { + var config = MakeReportDrawer.Config() + config.onReport = { [weak self] in + guard let self = self else { return } + let screenshot = try! self.makeAppScreenshot() + self.viewModel.report(contact: contact, screenshot: screenshot) { + self.collectionView.reloadData() + } + } + let drawer = makeReportDrawer(config) + coordinator.toDrawer(drawer, from: self) + } + private func makeWaitingRoundDrawer() -> UIViewController { let text = DrawerText( font: Fonts.Mulish.semiBold.font(size: 14.0), @@ -332,8 +363,7 @@ extension GroupChatController: UICollectionViewDataSource { var isSenderBanned = false - if let database = try? DependencyInjection.Container.shared.resolve() as Database, - let sender = try? database.fetchContacts(.init(id: [item.senderId])).first { + if let sender = try? session.dbManager.fetchContacts(.init(id: [item.senderId])).first { isSenderBanned = sender.isBanned } @@ -568,6 +598,10 @@ extension GroupChatController: UICollectionViewDelegate { self?.viewModel.didRequestDelete([item]) } + let report = UIAction(title: Localized.Chat.BubbleMenu.report, state: .off) { [weak self] _ in + self?.viewModel.didRequestReport(item) + } + let retry = UIAction(title: Localized.Chat.BubbleMenu.retry, state: .off) { [weak self] _ in self?.viewModel.retry(item) } @@ -579,7 +613,7 @@ extension GroupChatController: UICollectionViewDelegate { } else if item.status == .sending { menu = UIMenu(title: "", children: [copy]) } else { - menu = UIMenu(title: "", children: [copy, reply, delete]) + menu = UIMenu(title: "", children: [copy, reply, delete, report]) } return menu diff --git a/Sources/ChatFeature/ViewModels/GroupChatViewModel.swift b/Sources/ChatFeature/ViewModels/GroupChatViewModel.swift index ece776aea52bd2cb94e6ac8bcc6b2e7068b2bc3b..e9ca955393db78a4ccfc23bb52089032647bfcde 100644 --- a/Sources/ChatFeature/ViewModels/GroupChatViewModel.swift +++ b/Sources/ChatFeature/ViewModels/GroupChatViewModel.swift @@ -1,10 +1,15 @@ +import HUD import UIKit import Models +import Shared import Combine import XXModels +import Defaults import Foundation import Integration +import ToastFeature import DifferenceKit +import ReportingFeature import DependencyInjection enum GroupChatNavigationRoutes: Equatable { @@ -14,6 +19,18 @@ enum GroupChatNavigationRoutes: Equatable { final class GroupChatViewModel { @Dependency private var session: SessionType + @Dependency private var sendReport: SendReport + @Dependency private var toastController: ToastController + + @KeyObject(.username, defaultValue: nil) var username: String? + + var hudPublisher: AnyPublisher<HUDStatus, Never> { + hudSubject.eraseToAnyPublisher() + } + + var reportPopupPublisher: AnyPublisher<Contact, Never> { + reportPopupSubject.eraseToAnyPublisher() + } var replyPublisher: AnyPublisher<(String, String), Never> { replySubject.eraseToAnyPublisher() @@ -26,11 +43,13 @@ final class GroupChatViewModel { let info: GroupInfo private var stagedReply: Reply? private var cancellables = Set<AnyCancellable>() + private let hudSubject = CurrentValueSubject<HUDStatus, Never>(.none) + private let reportPopupSubject = PassthroughSubject<Contact, Never>() private let replySubject = PassthroughSubject<(String, String), Never>() private let routesSubject = PassthroughSubject<GroupChatNavigationRoutes, Never>() var messages: AnyPublisher<[ArraySection<ChatSection, Message>], Never> { - session.dbManager.fetchMessagesPublisher(.init(chat: .group(info.group.id), isSenderBanned: false)) + session.dbManager.fetchMessagesPublisher(.init(chat: .group(info.group.id))) .assertNoFailure() .map { messages -> [ArraySection<ChatSection, Message>] in let groupedByDate = Dictionary(grouping: messages) { domainModel -> Date in @@ -63,6 +82,12 @@ final class GroupChatViewModel { _ = try? session.dbManager.deleteMessages(.init(id: Set(messages.map(\.id)))) } + func didRequestReport(_ message: Message) { + if let contact = try? session.dbManager.fetchContacts(.init(id: [message.senderId])).first { + reportPopupSubject.send(contact) + } + } + func send(_ text: String) { session.send(.init( text: text.trimmingCharacters(in: .whitespacesAndNewlines), @@ -117,4 +142,57 @@ final class GroupChatViewModel { stagedReply = Reply(messageId: networkId, senderId: message.senderId) replySubject.send(getReplyContent(for: networkId)) } + + func report(contact: Contact, screenshot: UIImage, completion: @escaping () -> Void) { + let report = Report( + sender: .init( + userId: contact.id.base64EncodedString(), + username: contact.username! + ), + recipient: .init( + userId: session.myId.base64EncodedString(), + username: username! + ), + type: .group, + screenshot: screenshot.pngData()!, + partyName: info.group.name, + partyBlob: info.group.id.base64EncodedString(), + partyMembers: info.members.map { Report.ReportUser( + userId: $0.id.base64EncodedString(), + username: $0.username ?? "") + } + ) + + hudSubject.send(.on) + sendReport(report) { result in + switch result { + case .failure(let error): + DispatchQueue.main.async { + self.hudSubject.send(.error(.init(with: error))) + } + + case .success(_): + self.blockContact(contact) + DispatchQueue.main.async { + self.hudSubject.send(.none) + self.presentReportConfirmation(contact: contact) + completion() + } + } + } + } + + private func blockContact(_ contact: Contact) { + var contact = contact + contact.isBlocked = true + _ = try? session.dbManager.saveContact(contact) + } + + private func presentReportConfirmation(contact: Contact) { + let name = (contact.nickname ?? contact.username) ?? "the contact" + toastController.enqueueToast(model: .init( + title: "Your report has been sent and \(name) is now blocked.", + leftImage: Asset.requestSentToaster.image + )) + } } diff --git a/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift b/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift index 0e9b4c5e5dac0bf0611e346f524071ef4f08f04d..d3918b04206d620af85be2266599369bf9ab85ea 100644 --- a/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift +++ b/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift @@ -139,15 +139,11 @@ final class ChatListViewModel { ), groupChatInfoQuery: GroupChatInfo.Query( authStatus: [.participating], - isLeaderBlocked: false, - isLeaderBanned: false, excludeBannedContactsMessages: true ), groupQuery: Group.Query( withMessages: false, - authStatus: [.participating], - isLeaderBlocked: false, - isLeaderBanned: false + authStatus: [.participating] ) )) .assertNoFailure() diff --git a/Sources/Integration/Session/Session+Group.swift b/Sources/Integration/Session/Session+Group.swift index a3cf68964a307dceecb77893eb5f67fe15367bab..47652ee94cc4ec7d870005ff69d2d21ea3839ce3 100644 --- a/Sources/Integration/Session/Session+Group.swift +++ b/Sources/Integration/Session/Session+Group.swift @@ -53,7 +53,7 @@ extension Session { recipientId: nil, groupId: group.id, date: group.createdAt, - status: .received, + status: .sent, isUnread: false, text: welcome, replyMessageId: nil, diff --git a/Sources/ReportingFeature/Report.swift b/Sources/ReportingFeature/Report.swift index 61607f7b53e598b468c8f9ecbe23aab10cfb5b02..c2032b6a6b87d096f8f6883085d4c6e887630600 100644 --- a/Sources/ReportingFeature/Report.swift +++ b/Sources/ReportingFeature/Report.swift @@ -5,18 +5,27 @@ public struct Report: Encodable { sender: ReportUser, recipient: ReportUser, type: ReportType, - screenshot: Data + screenshot: Data, + partyName: String? = nil, + partyBlob: String? = nil, + partyMembers: [ReportUser]? = nil ) { self.sender = sender self.recipient = recipient self.type = type self.screenshot = screenshot + self.partyName = partyName + self.partyBlob = partyBlob + self.partyMembers = partyMembers } public var sender: ReportUser public var recipient: ReportUser public var type: ReportType public var screenshot: Data + public var partyName: String? + public var partyBlob: String? + public var partyMembers: [ReportUser]? } extension Report { diff --git a/Sources/ReportingFeature/Resources/report_cert.der b/Sources/ReportingFeature/Resources/report_cert.der index 978f65098ea8f361f1f369194e24d68d258f4dc2..b040579312ef63ed54d63419ad2955e6160b9666 100644 Binary files a/Sources/ReportingFeature/Resources/report_cert.der and b/Sources/ReportingFeature/Resources/report_cert.der differ