From c5c24bbb6320651cdb50138687104b20194f9009 Mon Sep 17 00:00:00 2001
From: Bruno Muniz Azevedo Filho <bruno@elixxir.io>
Date: Fri, 12 Aug 2022 00:00:39 -0300
Subject: [PATCH] Implemented filtering for banned/blocked users and reporting

---
 .../Controllers/SheetController.swift         | 13 ++-
 .../Controllers/SingleChatController.swift    | 83 ++++++++++++++++++-
 .../Helpers/CellConfigurator.swift            |  5 ++
 .../ViewModels/SingleChatViewModel.swift      | 17 ++++
 Sources/ChatFeature/Views/SheetView.swift     | 40 +++++----
 .../ViewModel/ChatListViewModel.swift         |  6 +-
 .../ViewModels/ContactListViewModel.swift     |  6 +-
 .../ViewModels/CreateGroupViewModel.swift     |  2 +-
 .../ViewModels/MenuViewModel.swift            |  2 +-
 .../RequestsReceivedViewModel.swift           |  2 +-
 .../ViewModels/RequestsSentViewModel.swift    |  2 +-
 .../ViewModels/SearchLeftViewModel.swift      |  2 +-
 Sources/Shared/AutoGenerated/Strings.swift    | 14 ++++
 .../Resources/en.lproj/Localizable.strings    | 15 ++++
 14 files changed, 180 insertions(+), 29 deletions(-)

diff --git a/Sources/ChatFeature/Controllers/SheetController.swift b/Sources/ChatFeature/Controllers/SheetController.swift
index f0f4fde8..974f086d 100644
--- a/Sources/ChatFeature/Controllers/SheetController.swift
+++ b/Sources/ChatFeature/Controllers/SheetController.swift
@@ -5,6 +5,7 @@ final class SheetController: UIViewController {
     enum Action {
         case clear
         case details
+        case report
     }
 
     lazy private var screenView = SheetView()
@@ -23,7 +24,7 @@ final class SheetController: UIViewController {
     public override func viewDidLoad() {
         super.viewDidLoad()
 
-        screenView.clear
+        screenView.clearButton
             .publisher(for: .touchUpInside)
             .sink { [unowned self] in
                 dismiss(animated: true) { [weak actionRelay] in
@@ -31,12 +32,20 @@ final class SheetController: UIViewController {
                 }
             }.store(in: &cancellables)
 
-        screenView.details
+        screenView.detailsButton
             .publisher(for: .touchUpInside)
             .sink { [unowned self] in
                 dismiss(animated: true) { [weak actionRelay] in
                     actionRelay?.send(.details)
                 }
             }.store(in: &cancellables)
+
+        screenView.reportButton
+            .publisher(for: .touchUpInside)
+            .sink { [unowned self] in
+                dismiss(animated: true) { [weak actionRelay] in
+                    actionRelay?.send(.report)
+                }
+            }.store(in: &cancellables)
     }
 }
diff --git a/Sources/ChatFeature/Controllers/SingleChatController.swift b/Sources/ChatFeature/Controllers/SingleChatController.swift
index d1d7785a..81fa1c7c 100644
--- a/Sources/ChatFeature/Controllers/SingleChatController.swift
+++ b/Sources/ChatFeature/Controllers/SingleChatController.swift
@@ -187,6 +187,7 @@ public final class SingleChatController: UIViewController {
 
         navigationItem.rightBarButtonItem = UIBarButtonItem(customView: moreButton)
         navigationItem.leftBarButtonItem = UIBarButtonItem(customView: infoView)
+        navigationItem.leftItemsSupplementBackButton = true
     }
 
     private func setupInputController() {
@@ -249,6 +250,8 @@ public final class SingleChatController: UIViewController {
                     presentDeleteAllDrawer()
                 case .details:
                     coordinator.toContact(viewModel.contact, from: self)
+                case .report:
+                    presentReportDrawer()
                 }
             }.store(in: &cancellables)
 
@@ -263,6 +266,12 @@ public final class SingleChatController: UIViewController {
                 }
             }.store(in: &cancellables)
 
+        viewModel.reportPopupPublisher
+            .receive(on: DispatchQueue.main)
+            .sink { [unowned self] in
+                presentReportDrawer()
+            }.store(in: &cancellables)
+
         viewModel.isOnline
             .removeDuplicates()
             .receive(on: DispatchQueue.main)
@@ -383,6 +392,77 @@ public final class SingleChatController: UIViewController {
         return drawer
     }
 
+    private func presentReportDrawer() {
+        let cancelButton = CapsuleButton()
+        cancelButton.setStyle(.seeThrough)
+        cancelButton.setTitle(Localized.Chat.Report.cancel, for: .normal)
+
+        let reportButton = CapsuleButton()
+        reportButton.setStyle(.red)
+        reportButton.setTitle(Localized.Chat.Report.action, for: .normal)
+
+        let drawer = DrawerController(with: [
+            DrawerImage(
+                image: Asset.drawerNegative.image
+            ),
+            DrawerText(
+                font: Fonts.Mulish.semiBold.font(size: 18.0),
+                text: Localized.Chat.Report.title,
+                color: Asset.neutralActive.color
+            ),
+            DrawerText(
+                font: Fonts.Mulish.semiBold.font(size: 14.0),
+                text: Localized.Chat.Report.subtitle,
+                color: Asset.neutralWeak.color,
+                lineHeightMultiple: 1.35,
+                spacingAfter: 25
+            ),
+            DrawerStack(
+                axis: .vertical,
+                spacing: 20.0,
+                views: [reportButton, cancelButton]
+            )
+        ])
+
+        reportButton.publisher(for: .touchUpInside)
+            .receive(on: DispatchQueue.main)
+            .sink {
+                drawer.dismiss(animated: true) { [weak self] in
+                    guard let self = self else { return }
+                    self.drawerCancellables.removeAll()
+                    self.didProceedWithReport()
+                }
+            }.store(in: &drawerCancellables)
+
+        cancelButton.publisher(for: .touchUpInside)
+            .receive(on: DispatchQueue.main)
+            .sink {
+                drawer.dismiss(animated: true) { [weak self] in
+                    self?.drawerCancellables.removeAll()
+                }
+            }.store(in: &drawerCancellables)
+
+        coordinator.toDrawer(drawer, from: self)
+    }
+
+    private func didProceedWithReport() {
+        var screenshotImage: UIImage?
+
+        let layer = UIApplication.shared.keyWindow!.layer
+
+        let scale = UIScreen.main.scale
+        UIGraphicsBeginImageContextWithOptions(layer.frame.size, false, scale);
+        guard let context = UIGraphicsGetCurrentContext() else { return }
+        layer.render(in: context)
+
+        if let image = UIGraphicsGetImageFromCurrentImageContext() {
+            UIGraphicsEndImageContext()
+            UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
+            viewModel.uploadReport(screenshot: image)
+            navigationController?.popViewController(animated: true)
+        }
+    }
+
     private func presentDeleteAllDrawer() {
         let clearButton = CapsuleButton()
         clearButton.setStyle(.red)
@@ -632,7 +712,8 @@ extension SingleChatController: UICollectionViewDelegate {
                 ActionFactory.build(from: item, action: .copy, closure: self.viewModel.didRequestCopy(_:)),
                 ActionFactory.build(from: item, action: .retry, closure: self.viewModel.didRequestRetry(_:)),
                 ActionFactory.build(from: item, action: .reply, closure: self.viewModel.didRequestReply(_:)),
-                ActionFactory.build(from: item, action: .delete, closure: self.viewModel.didRequestDeleteSingle(_:))
+                ActionFactory.build(from: item, action: .delete, closure: self.viewModel.didRequestDeleteSingle(_:)),
+                ActionFactory.build(from: item, action: .report, closure: self.viewModel.didRequestReport(_:))
             ].compactMap { $0 })
         }
     }
diff --git a/Sources/ChatFeature/Helpers/CellConfigurator.swift b/Sources/ChatFeature/Helpers/CellConfigurator.swift
index 4f0ad3ca..d9b61fd9 100644
--- a/Sources/ChatFeature/Helpers/CellConfigurator.swift
+++ b/Sources/ChatFeature/Helpers/CellConfigurator.swift
@@ -399,6 +399,7 @@ struct ActionFactory {
         case retry
         case reply
         case delete
+        case report
 
         var title: String {
             switch self {
@@ -411,6 +412,8 @@ struct ActionFactory {
                 return Localized.Chat.BubbleMenu.reply
             case .delete:
                 return Localized.Chat.BubbleMenu.delete
+            case .report:
+                return Localized.Chat.BubbleMenu.report
             }
         }
     }
@@ -422,6 +425,8 @@ struct ActionFactory {
     ) -> UIAction? {
 
         switch action {
+        case .report:
+            guard item.status == .received else { return nil }
         case .reply:
             guard item.status == .received || item.status == .sent else { return nil }
         case .retry:
diff --git a/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift b/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift
index f51a8936..cf9404a3 100644
--- a/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift
+++ b/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift
@@ -34,6 +34,7 @@ final class SingleChatViewModel {
     private let replySubject = PassthroughSubject<(String, String), Never>()
     private let navigationRoutes = PassthroughSubject<SingleChatNavigationRoutes, Never>()
     private let sectionsRelay = CurrentValueSubject<[ArraySection<ChatSection, Message>], Never>([])
+    private let reportPopupSubject = PassthroughSubject<Void, Never>()
 
     var hud: AnyPublisher<HUDStatus, Never> { hudRelay.eraseToAnyPublisher() }
     private let hudRelay = CurrentValueSubject<HUDStatus, Never>(.none)
@@ -44,6 +45,10 @@ final class SingleChatViewModel {
     var navigation: AnyPublisher<SingleChatNavigationRoutes, Never> { navigationRoutes.eraseToAnyPublisher() }
     var shouldDisplayEmptyView: AnyPublisher<Bool, Never> { sectionsRelay.map { $0.isEmpty }.eraseToAnyPublisher() }
 
+    var reportPopupPublisher: AnyPublisher<Void, Never> {
+        reportPopupSubject.eraseToAnyPublisher()
+    }
+
     var messages: AnyPublisher<[ArraySection<ChatSection, Message>], Never> {
         sectionsRelay.map { sections -> [ArraySection<ChatSection, Message>] in
             var snapshot = [ArraySection<ChatSection, Message>]()
@@ -172,6 +177,10 @@ final class SingleChatViewModel {
         didRequestDelete([model])
     }
 
+    func didRequestReport(_: Message) {
+        reportPopupSubject.send()
+    }
+
     func abortReply() {
         stagedReply = nil
     }
@@ -211,6 +220,14 @@ final class SingleChatViewModel {
         return (contactTitle, message.text)
     }
 
+    func uploadReport(screenshot: UIImage) {
+        UIImageWriteToSavedPhotosAlbum(screenshot, nil, nil, nil)
+
+        var contact = contact
+        contact.isBlocked = true
+        _ = try? session.dbManager.saveContact(contact)
+    }
+
     func showRoundFrom(_ roundURL: String?) {
         if let urlString = roundURL, !urlString.isEmpty {
             navigationRoutes.send(.webview(urlString))
diff --git a/Sources/ChatFeature/Views/SheetView.swift b/Sources/ChatFeature/Views/SheetView.swift
index 86459e76..a4cdfeb1 100644
--- a/Sources/ChatFeature/Views/SheetView.swift
+++ b/Sources/ChatFeature/Views/SheetView.swift
@@ -2,9 +2,10 @@ import UIKit
 import Shared
 
 final class SheetView: UIView {
-    let stack = UIStackView()
-    let clear = SheetButton()
-    let details = SheetButton()
+    let stackView = UIStackView()
+    let clearButton = SheetButton()
+    let reportButton = SheetButton()
+    let detailsButton = SheetButton()
 
     init() {
         super.init(frame: .zero)
@@ -13,23 +14,28 @@ final class SheetView: UIView {
         layer.masksToBounds = true
         backgroundColor = Asset.neutralWhite.color
 
-        clear.image.image = Asset.chatListDeleteSwipe.image
-        clear.title.text = Localized.Chat.SheetMenu.clear
+        clearButton.image.image = Asset.chatListDeleteSwipe.image
+        clearButton.title.text = Localized.Chat.SheetMenu.clear
 
-        details.tintColor = Asset.neutralDark.color
-        details.image.image = Asset.searchUsername.image
-        details.title.text = Localized.Chat.SheetMenu.details
+        detailsButton.tintColor = Asset.neutralDark.color
+        detailsButton.image.image = Asset.searchUsername.image
+        detailsButton.title.text = Localized.Chat.SheetMenu.details
 
-        stack.axis = .vertical
-        stack.distribution = .fillEqually
-        stack.addArrangedSubview(clear)
-        stack.addArrangedSubview(details)
-        addSubview(stack)
+        reportButton.tintColor = Asset.accentDanger.color
+        reportButton.image.image = Asset.searchUsername.image
+        reportButton.title.text = Localized.Chat.SheetMenu.report
 
-        stack.snp.makeConstraints { make in
-            make.top.equalToSuperview().offset(25)
-            make.left.right.equalToSuperview()
-            make.bottom.equalTo(safeAreaLayoutGuide)
+        stackView.axis = .vertical
+        stackView.distribution = .fillEqually
+        stackView.addArrangedSubview(clearButton)
+        stackView.addArrangedSubview(detailsButton)
+        stackView.addArrangedSubview(reportButton)
+        addSubview(stackView)
+
+        stackView.snp.makeConstraints {
+            $0.top.equalToSuperview().offset(25)
+            $0.left.right.equalToSuperview()
+            $0.bottom.equalTo(safeAreaLayoutGuide)
         }
     }
 
diff --git a/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift b/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift
index f481b96f..efd5e463 100644
--- a/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift
+++ b/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift
@@ -107,7 +107,7 @@ final class ChatListViewModel {
             .confirmationFailed,
             .verificationFailed,
             .verificationInProgress
-        ])
+        ], isBlocked: false, isBanned: false)
 
         return Publishers.CombineLatest(
             session.dbManager.fetchContactsPublisher(contactsQuery).assertNoFailure(),
@@ -127,7 +127,9 @@ final class ChatListViewModel {
             ChatInfo.Query(
                 contactChatInfoQuery: .init(
                     userId: session.myId,
-                    authStatus: [.friend]
+                    authStatus: [.friend],
+                    isBlocked: false,
+                    isBanned: false
                 ),
                 groupChatInfoQuery: GroupChatInfo.Query(
                     authStatus: [.participating]
diff --git a/Sources/ContactListFeature/ViewModels/ContactListViewModel.swift b/Sources/ContactListFeature/ViewModels/ContactListViewModel.swift
index 04674460..f3fea877 100644
--- a/Sources/ContactListFeature/ViewModels/ContactListViewModel.swift
+++ b/Sources/ContactListFeature/ViewModels/ContactListViewModel.swift
@@ -8,7 +8,9 @@ final class ContactListViewModel {
     @Dependency private var session: SessionType
 
     var contacts: AnyPublisher<[Contact], Never> {
-        session.dbManager.fetchContactsPublisher(.init(authStatus: [.friend]))
+        let query = Contact.Query(authStatus: [.friend], isBlocked: false, isBanned: false)
+
+        return session.dbManager.fetchContactsPublisher(query)
             .assertNoFailure()
             .map { $0.filter { $0.id != self.session.myId }}
             .eraseToAnyPublisher()
@@ -22,7 +24,7 @@ final class ContactListViewModel {
             .confirmationFailed,
             .verificationFailed,
             .verificationInProgress
-        ])
+        ], isBlocked: false, isBanned: false)
 
         return Publishers.CombineLatest(
             session.dbManager.fetchContactsPublisher(contactsQuery).assertNoFailure(),
diff --git a/Sources/ContactListFeature/ViewModels/CreateGroupViewModel.swift b/Sources/ContactListFeature/ViewModels/CreateGroupViewModel.swift
index a8f94de8..668b396b 100644
--- a/Sources/ContactListFeature/ViewModels/CreateGroupViewModel.swift
+++ b/Sources/ContactListFeature/ViewModels/CreateGroupViewModel.swift
@@ -42,7 +42,7 @@ final class CreateGroupViewModel {
     // MARK: Lifecycle
 
     init() {
-        session.dbManager.fetchContactsPublisher(.init(authStatus: [.friend]))
+        session.dbManager.fetchContactsPublisher(.init(authStatus: [.friend], isBlocked: false, isBanned: false))
             .assertNoFailure()
             .map { $0.filter { $0.id != self.session.myId }}
             .map { $0.sorted(by: { $0.username! < $1.username! })}
diff --git a/Sources/MenuFeature/ViewModels/MenuViewModel.swift b/Sources/MenuFeature/ViewModels/MenuViewModel.swift
index b4ebd757..4d8b0db9 100644
--- a/Sources/MenuFeature/ViewModels/MenuViewModel.swift
+++ b/Sources/MenuFeature/ViewModels/MenuViewModel.swift
@@ -19,7 +19,7 @@ final class MenuViewModel {
             .confirmationFailed,
             .verificationFailed,
             .verificationInProgress
-        ])
+        ], isBlocked: false, isBanned: false)
 
         return Publishers.CombineLatest(
             session.dbManager.fetchContactsPublisher(contactsQuery).assertNoFailure(),
diff --git a/Sources/RequestsFeature/ViewModels/RequestsReceivedViewModel.swift b/Sources/RequestsFeature/ViewModels/RequestsReceivedViewModel.swift
index ded18f77..57efb1ea 100644
--- a/Sources/RequestsFeature/ViewModels/RequestsReceivedViewModel.swift
+++ b/Sources/RequestsFeature/ViewModels/RequestsReceivedViewModel.swift
@@ -65,7 +65,7 @@ final class RequestsReceivedViewModel {
                 .verified,
                 .verificationFailed,
                 .verificationInProgress
-            ])
+            ], isBlocked: false, isBanned: false)
 
         let groupStream = session.dbManager.fetchGroupsPublisher(groupsQuery).assertNoFailure()
         let contactsStream = session.dbManager.fetchContactsPublisher(contactsQuery).assertNoFailure()
diff --git a/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift b/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift
index f94ed8f5..26a6f485 100644
--- a/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift
+++ b/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift
@@ -36,7 +36,7 @@ final class RequestsSentViewModel {
         let query = Contact.Query(authStatus: [
             .requested,
             .requesting
-        ])
+        ], isBlocked: false, isBanned: false)
 
         session.dbManager.fetchContactsPublisher(query)
             .assertNoFailure()
diff --git a/Sources/SearchFeature/ViewModels/SearchLeftViewModel.swift b/Sources/SearchFeature/ViewModels/SearchLeftViewModel.swift
index 9f9d0ffd..7d49d19e 100644
--- a/Sources/SearchFeature/ViewModels/SearchLeftViewModel.swift
+++ b/Sources/SearchFeature/ViewModels/SearchLeftViewModel.swift
@@ -144,7 +144,7 @@ final class SearchLeftViewModel {
         var snapshot = SearchSnapshot()
 
         if var user = user {
-            if let contact = try? session.dbManager.fetchContacts(.init(id: [user.id])).first {
+            if let contact = try? session.dbManager.fetchContacts(.init(id: [user.id], isBlocked: false, isBanned: false)).first {
                 user.authStatus = contact.authStatus
             }
 
diff --git a/Sources/Shared/AutoGenerated/Strings.swift b/Sources/Shared/AutoGenerated/Strings.swift
index 03ada00d..ee37eb48 100644
--- a/Sources/Shared/AutoGenerated/Strings.swift
+++ b/Sources/Shared/AutoGenerated/Strings.swift
@@ -324,6 +324,8 @@ public enum Localized {
       public static let delete = Localized.tr("Localizable", "chat.bubbleMenu.delete")
       /// Reply
       public static let reply = Localized.tr("Localizable", "chat.bubbleMenu.reply")
+      /// Report
+      public static let report = Localized.tr("Localizable", "chat.bubbleMenu.report")
       /// Retry
       public static let retry = Localized.tr("Localizable", "chat.bubbleMenu.retry")
       /// Select
@@ -351,6 +353,16 @@ public enum Localized {
       /// All
       public static let deleteAll = Localized.tr("Localizable", "chat.menu.deleteAll")
     }
+    public enum Report {
+      /// Confirm and Report
+      public static let action = Localized.tr("Localizable", "chat.report.action")
+      /// Cancel
+      public static let cancel = Localized.tr("Localizable", "chat.report.cancel")
+      /// Reporting this user will block them, delete them from your connections and you won’t see direct messages from them again. In case this user is marked as banned user by us you won’t also see any new group chat msgs from this user
+      public static let subtitle = Localized.tr("Localizable", "chat.report.subtitle")
+      /// Report user
+      public static let title = Localized.tr("Localizable", "chat.report.title")
+    }
     public enum RetrySheet {
       /// Cancel
       public static let cancel = Localized.tr("Localizable", "chat.retrySheet.cancel")
@@ -370,6 +382,8 @@ public enum Localized {
       public static let clear = Localized.tr("Localizable", "chat.sheetMenu.clear")
       /// View contact profile
       public static let details = Localized.tr("Localizable", "chat.sheetMenu.details")
+      /// Report user
+      public static let report = Localized.tr("Localizable", "chat.sheetMenu.report")
     }
   }
 
diff --git a/Sources/Shared/Resources/en.lproj/Localizable.strings b/Sources/Shared/Resources/en.lproj/Localizable.strings
index 033e7a6f..5d910155 100644
--- a/Sources/Shared/Resources/en.lproj/Localizable.strings
+++ b/Sources/Shared/Resources/en.lproj/Localizable.strings
@@ -114,6 +114,8 @@
 = "Select";
 "chat.bubbleMenu.retry"
 = "Retry";
+"chat.bubbleMenu.report"
+= "Report";
 
 "chat.e2e.placeholder"
 = "You and %@ now have a #quantum-secure#, completely private channel for messaging.\n#Say hello#!";
@@ -125,6 +127,8 @@
 = "Clear chat";
 "chat.sheetMenu.details"
 = "View contact profile";
+"chat.sheetMenu.report"
+= "Report user";
 "chat.retrySheet.retry"
 = "Try again";
 "chat.retrySheet.delete"
@@ -174,6 +178,17 @@
 "chat.clear.cancel"
 = "Cancel";
 
+// ChatFeature - Report
+
+"chat.report.title"
+= "Report user";
+"chat.report.subtitle"
+= "Reporting this user will block them, delete them from your connections and you won’t see direct messages from them again. In case this user is marked as banned user by us you won’t also see any new group chat msgs from this user";
+"chat.report.action"
+= "Confirm and Report";
+"chat.report.cancel"
+= "Cancel";
+
 // ScanFeature
 
 "scan.status.reading"
-- 
GitLab