From 579f64f089394acd6c07fa46f8496577c7eb1e48 Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Sun, 29 Nov 2020 18:54:11 -0800 Subject: [PATCH] Reporting --- Localizations/Localizable.strings | 7 ++ .../Sources/Mastodon/Entities/Report.swift | 12 +++ .../Endpoints/ReportEndpoint.swift | 62 +++++++++++ Metatext.xcodeproj/project.pbxproj | 10 +- .../Entities/ReportElements.swift | 5 + .../Services/AccountService.swift | 18 +++- View Controllers/ProfileViewController.swift | 14 ++- View Controllers/TableViewController.swift | 9 ++ .../PreviewViewModels/PreviewViewModels.swift | 37 ++++++- .../Sources/ViewModels/AccountViewModel.swift | 4 + .../Entities/CollectionItemEvent.swift | 1 + .../Sources/ViewModels/ReportViewModel.swift | 62 +++++++++++ .../Sources/ViewModels/StatusViewModel.swift | 11 ++ Views/ReportView.swift | 101 ++++++++++++++++++ Views/Status/ReportStatusView.swift | 27 +++++ Views/Status/StatusView.swift | 12 ++- 16 files changed, 379 insertions(+), 13 deletions(-) create mode 100644 Mastodon/Sources/Mastodon/Entities/Report.swift create mode 100644 MastodonAPI/Sources/MastodonAPI/Endpoints/ReportEndpoint.swift create mode 100644 ServiceLayer/Sources/ServiceLayer/Entities/ReportElements.swift create mode 100644 ViewModels/Sources/ViewModels/ReportViewModel.swift create mode 100644 Views/ReportView.swift create mode 100644 Views/Status/ReportStatusView.swift diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index 2402bbf..fcf94cf 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -114,6 +114,12 @@ "notifications.poll-ended" = "A poll you have voted in has ended"; "notifications.your-poll-ended" = "Your poll has ended"; "notifications.unknown" = "Notification from %@"; +"report" = "Report"; +"report.hint" = "The report will be sent to your server moderators. You can provide an explanation of why you are reporting this account below:"; +"report.placeholder" = "Additional comments"; +"report.target-%@" = "Reporting %@"; +"report.forward.hint" = "The account is from another server. Send an anonymized copy of the report there as well?"; +"report.forward-%@" = "Forward report to %@"; "status.reblogged-by" = "%@ boosted"; "status.pinned-post" = "Pinned post"; "status.show-more" = "Show More"; @@ -125,6 +131,7 @@ "status.visibility.public" = "Public"; "status.visibility.unlisted" = "Unlisted"; "status.visibility.private" = "Private"; +"submit" = "Submit"; "timelines.home" = "Home"; "timelines.local" = "Local"; "timelines.federated" = "Federated"; diff --git a/Mastodon/Sources/Mastodon/Entities/Report.swift b/Mastodon/Sources/Mastodon/Entities/Report.swift new file mode 100644 index 0000000..70cc5a4 --- /dev/null +++ b/Mastodon/Sources/Mastodon/Entities/Report.swift @@ -0,0 +1,12 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +public struct Report: Codable, Hashable { + public let id: Id + public let actionTaken: Bool +} + +public extension Report { + typealias Id = String +} diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/ReportEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/ReportEndpoint.swift new file mode 100644 index 0000000..ef5152e --- /dev/null +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/ReportEndpoint.swift @@ -0,0 +1,62 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import HTTP +import Mastodon + +public enum ReportEndpoint { + case create(Elements) +} + +public extension ReportEndpoint { + struct Elements { + public let accountId: Account.Id + public var statusIds = Set() + public var comment = "" + public var forward = false + + public init(accountId: Account.Id) { + self.accountId = accountId + } + } +} + +extension ReportEndpoint: Endpoint { + public typealias ResultType = Report + + public var context: [String] { + defaultContext + ["reports"] + } + + public var pathComponentsInContext: [String] { + [] + } + + public var jsonBody: [String: Any]? { + switch self { + case let .create(creation): + var params: [String: Any] = ["account_id": creation.accountId] + + if !creation.statusIds.isEmpty { + params["status_ids"] = Array(creation.statusIds) + } + + if !creation.comment.isEmpty { + params["comment"] = creation.comment + } + + if creation.forward { + params["forward"] = creation.forward + } + + return params + } + } + + public var method: HTTPMethod { + switch self { + case .create: + return .post + } + } +} diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 3118687..28b1b17 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ D08B8D822544D80000B1EBEF /* PollOptionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D812544D80000B1EBEF /* PollOptionButton.swift */; }; D08B8D8D2544E6EC00B1EBEF /* PollResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D8C2544E6EC00B1EBEF /* PollResultView.swift */; }; D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */; }; + D0A7AC7325748BFF00E4E8AB /* ReportStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */; }; D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; }; D0B5FE9B251583DB00478838 /* ProfileCollection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */; }; D0B8510C25259E56004E0744 /* LoadMoreCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B8510B25259E56004E0744 /* LoadMoreCell.swift */; }; @@ -71,6 +72,7 @@ D0C7D4D724F7616A001EBDBB /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D46C24F76169001EBDBB /* UIColor+Extensions.swift */; }; D0C7D4D924F7616A001EBDBB /* KingfisherOptionsInfo+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D46E24F76169001EBDBB /* KingfisherOptionsInfo+Extensions.swift */; }; D0C7D4DA24F7616A001EBDBB /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D46F24F76169001EBDBB /* View+Extensions.swift */; }; + D0DD50CB256B1F24004A04F7 /* ReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DD50CA256B1F24004A04F7 /* ReportView.swift */; }; D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E1F582251F13EC00D45315 /* WebfingerIndicatorView.swift */; }; D0E2C1D124FD97F000854680 /* ViewModels in Frameworks */ = {isa = PBXBuildFile; productRef = D0E2C1D024FD97F000854680 /* ViewModels */; }; D0E5361C24E3EB4D00FB1CE1 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E5361B24E3EB4D00FB1CE1 /* NotificationService.swift */; }; @@ -156,6 +158,7 @@ D08B8D812544D80000B1EBEF /* PollOptionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionButton.swift; sourceTree = ""; }; D08B8D8C2544E6EC00B1EBEF /* PollResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollResultView.swift; sourceTree = ""; }; D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = ""; }; + D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportStatusView.swift; sourceTree = ""; }; D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = ""; }; D0B32F4F250B373600311912 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = ""; }; D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileCollection+Extensions.swift"; sourceTree = ""; }; @@ -193,6 +196,7 @@ D0C7D46E24F76169001EBDBB /* KingfisherOptionsInfo+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "KingfisherOptionsInfo+Extensions.swift"; sourceTree = ""; }; D0C7D46F24F76169001EBDBB /* View+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; }; D0D7C013250440610039AD6F /* CodableBloomFilter */ = {isa = PBXFileReference; lastKnownFileType = folder; path = CodableBloomFilter; sourceTree = ""; }; + D0DD50CA256B1F24004A04F7 /* ReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportView.swift; sourceTree = ""; }; D0E0F1E424FC49FC002C04BF /* Mastodon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Mastodon; sourceTree = ""; }; D0E1F582251F13EC00D45315 /* WebfingerIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebfingerIndicatorView.swift; sourceTree = ""; }; D0E2C1CF24FD8BA400854680 /* ViewModels */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ViewModels; sourceTree = ""; }; @@ -289,10 +293,11 @@ D0EA593F2522AC8700804347 /* CardView.swift */, D01F41E224F8889700D55A2D /* StatusAttachmentsView.swift */, D0BEB1F224F8EE8C001B0F04 /* StatusAttachmentView.swift */, - D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */, D036AA16254CA823009094DF /* StatusBodyView.swift */, + D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */, D0625E58250F092900502611 /* StatusListCell.swift */, D00CB2EC2533ACC00080096B /* StatusView.swift */, + D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */, ); path = Status; sourceTree = ""; @@ -374,6 +379,7 @@ D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */, D0C7D42624F76169001EBDBB /* PreferencesView.swift */, D0B32F4F250B373600311912 /* RegistrationView.swift */, + D0DD50CA256B1F24004A04F7 /* ReportView.swift */, D0C7D42724F76169001EBDBB /* RootView.swift */, D02E1F94250B13210071AD56 /* SafariView.swift */, D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */, @@ -637,6 +643,7 @@ D0C7D49A24F7616A001EBDBB /* TableView.swift in Sources */, D08B8D622540DE3B00B1EBEF /* ZoomTransitionController.swift in Sources */, D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */, + D0DD50CB256B1F24004A04F7 /* ReportView.swift in Sources */, D0FE1C8F253686F9003EF1EB /* PlayerView.swift in Sources */, D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */, D036AA02254B6101009094DF /* NotificationListCell.swift in Sources */, @@ -683,6 +690,7 @@ D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */, D0C7D4C424F7616A001EBDBB /* AppDelegate.swift in Sources */, D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */, + D0A7AC7325748BFF00E4E8AB /* ReportStatusView.swift in Sources */, D0C7D4C324F7616A001EBDBB /* MetatextApp.swift in Sources */, D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */, D0BEB20524FA1107001B0F04 /* FiltersView.swift in Sources */, diff --git a/ServiceLayer/Sources/ServiceLayer/Entities/ReportElements.swift b/ServiceLayer/Sources/ServiceLayer/Entities/ReportElements.swift new file mode 100644 index 0000000..69fd9da --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Entities/ReportElements.swift @@ -0,0 +1,5 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import MastodonAPI + +public typealias ReportElements = ReportEndpoint.Elements diff --git a/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift b/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift index 04243c3..f89ca84 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift @@ -15,11 +15,11 @@ public struct AccountService { private let mastodonAPIClient: MastodonAPIClient private let contentDatabase: ContentDatabase - init(account: Account, - relationship: Relationship? = nil, - identityProofs: [IdentityProof] = [], - mastodonAPIClient: MastodonAPIClient, - contentDatabase: ContentDatabase) { + public init(account: Account, + relationship: Relationship? = nil, + identityProofs: [IdentityProof] = [], + mastodonAPIClient: MastodonAPIClient, + contentDatabase: ContentDatabase) { self.account = account self.relationship = relationship self.identityProofs = identityProofs @@ -30,6 +30,10 @@ public struct AccountService { } public extension AccountService { + var isLocal: Bool { + account.url.host == mastodonAPIClient.instanceURL.host + } + func follow() -> AnyPublisher { relationshipAction(.accountsFollow(id: account.id)) } @@ -82,6 +86,10 @@ public extension AccountService { func set(note: String) -> AnyPublisher { relationshipAction(.note(note, id: account.id)) } + + func report(_ elements: ReportElements) -> AnyPublisher { + mastodonAPIClient.request(ReportEndpoint.create(elements)).ignoreOutput().eraseToAnyPublisher() + } } private extension AccountService { diff --git a/View Controllers/ProfileViewController.swift b/View Controllers/ProfileViewController.swift index b4131c5..da42bed 100644 --- a/View Controllers/ProfileViewController.swift +++ b/View Controllers/ProfileViewController.swift @@ -2,7 +2,7 @@ import Combine import Mastodon -import UIKit +import SwiftUI import ViewModels final class ProfileViewController: TableViewController { @@ -66,6 +66,7 @@ final class ProfileViewController: TableViewController { } private extension ProfileViewController { + // swiftlint:disable:next function_body_length func menu(accountViewModel: AccountViewModel, relationship: Relationship) -> UIMenu { var actions = [UIAction]() @@ -99,6 +100,17 @@ private extension ProfileViewController { }) } + actions.append(UIAction( + title: NSLocalizedString("report", comment: ""), + image: UIImage(systemName: "flag"), + attributes: .destructive) { [weak self] _ in + guard let self = self, + let reportViewModel = self.viewModel.accountViewModel?.reportViewModel() + else { return } + + self.report(viewModel: reportViewModel) + }) + if relationship.blocking { actions.append(UIAction( title: NSLocalizedString("account.unblock", comment: ""), diff --git a/View Controllers/TableViewController.swift b/View Controllers/TableViewController.swift index f0eefa7..7d757e8 100644 --- a/View Controllers/TableViewController.swift +++ b/View Controllers/TableViewController.swift @@ -126,6 +126,13 @@ extension TableViewController { static let autoplayableAttachmentsView = PassthroughSubject() static let autoplayableAttachmentsViewNotification = Notification.Name("com.metabolist.metatext.attachment-view-became-autoplayable") + + func report(viewModel: ReportViewModel) { + let reportViewController = ReportViewController(viewModel: viewModel) + let navigationController = UINavigationController(rootViewController: reportViewController) + + present(navigationController, animated: true) + } } extension TableViewController: UITableViewDataSourcePrefetching { @@ -325,6 +332,8 @@ private extension TableViewController { } case let .attachment(attachmentViewModel, statusViewModel): present(attachmentViewModel: attachmentViewModel, statusViewModel: statusViewModel) + case let .report(reportViewModel): + report(viewModel: reportViewModel) } } diff --git a/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift b/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift index 1ba9bfe..6dd1cd2 100644 --- a/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift +++ b/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift @@ -4,6 +4,7 @@ import Combine import DB import Foundation import Mastodon +import MastodonAPI import MastodonAPIStubs import MockKeychain import Secrets @@ -13,23 +14,24 @@ import ViewModels // swiftlint:disable force_try +let identityId = Identity.Id() + let db: IdentityDatabase = { - let id = Identity.Id() let db = try! IdentityDatabase(inMemory: true, appGroup: "", keychain: MockKeychain.self) - let secrets = Secrets(identityId: id, keychain: MockKeychain.self) + let secrets = Secrets(identityId: identityId, keychain: MockKeychain.self) try! secrets.setInstanceURL(.previewInstanceURL) try! secrets.setAccessToken(UUID().uuidString) - _ = db.createIdentity(id: id, url: .previewInstanceURL, authenticated: true, pending: false) + _ = db.createIdentity(id: identityId, url: .previewInstanceURL, authenticated: true, pending: false) .receive(on: ImmediateScheduler.shared) .sink { _ in } receiveValue: { _ in } - _ = db.updateInstance(.preview, id: id) + _ = db.updateInstance(.preview, id: identityId) .receive(on: ImmediateScheduler.shared) .sink { _ in } receiveValue: { _ in } - _ = db.updateAccount(.preview, id: id) + _ = db.updateAccount(.preview, id: identityId) .receive(on: ImmediateScheduler.shared) .sink { _ in } receiveValue: { _ in } @@ -39,6 +41,22 @@ let db: IdentityDatabase = { let environment = AppEnvironment.mock(fixtureDatabase: db) let decoder = MastodonDecoder() +extension MastodonAPIClient { + static let preview = MastodonAPIClient( + session: URLSession(configuration: .stubbing), + instanceURL: .previewInstanceURL) +} + +extension ContentDatabase { + static let preview = try! ContentDatabase( + id: identityId, + useHomeTimelineLastReadId: false, + useNotificationsLastReadId: false, + inMemory: true, + appGroup: "group.metabolist.metatext", + keychain: MockKeychain.self) +} + public extension URL { static let previewInstanceURL = URL(string: "https://mastodon.social")! } @@ -59,4 +77,13 @@ public extension Identification { static let preview = RootViewModel.preview.navigationViewModel!.identification } +public extension ReportViewModel { + static let preview = ReportViewModel( + accountService: AccountService( + account: .preview, + mastodonAPIClient: .preview, + contentDatabase: .preview), + identification: .preview) +} + // swiftlint:enable force_try diff --git a/ViewModels/Sources/ViewModels/AccountViewModel.swift b/ViewModels/Sources/ViewModels/AccountViewModel.swift index 9dafa7f..bc26554 100644 --- a/ViewModels/Sources/ViewModels/AccountViewModel.swift +++ b/ViewModels/Sources/ViewModels/AccountViewModel.swift @@ -66,6 +66,10 @@ public extension AccountViewModel { .eraseToAnyPublisher()) } + func reportViewModel() -> ReportViewModel { + ReportViewModel(accountService: accountService, identification: identification) + } + func follow() { ignorableOutputEvent(accountService.follow()) } diff --git a/ViewModels/Sources/ViewModels/Entities/CollectionItemEvent.swift b/ViewModels/Sources/ViewModels/Entities/CollectionItemEvent.swift index 6e44485..8acd955 100644 --- a/ViewModels/Sources/ViewModels/Entities/CollectionItemEvent.swift +++ b/ViewModels/Sources/ViewModels/Entities/CollectionItemEvent.swift @@ -7,5 +7,6 @@ public enum CollectionItemEvent { case ignorableOutput case navigation(Navigation) case attachment(AttachmentViewModel, StatusViewModel) + case report(ReportViewModel) case share(URL) } diff --git a/ViewModels/Sources/ViewModels/ReportViewModel.swift b/ViewModels/Sources/ViewModels/ReportViewModel.swift new file mode 100644 index 0000000..efd0cc8 --- /dev/null +++ b/ViewModels/Sources/ViewModels/ReportViewModel.swift @@ -0,0 +1,62 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import Foundation +import Mastodon +import ServiceLayer + +public final class ReportViewModel: ObservableObject { + @Published public var elements: ReportElements + public let events: AnyPublisher + public let statusViewModel: StatusViewModel? + @Published public private(set) var loading = false + @Published public var alertItem: AlertItem? + + private let accountService: AccountService + private let eventsSubject = PassthroughSubject() + private var cancellables = Set() + + public init(accountService: AccountService, statusService: StatusService? = nil, identification: Identification) { + self.accountService = accountService + elements = ReportElements(accountId: accountService.account.id) + events = eventsSubject.eraseToAnyPublisher() + + if let statusService = statusService { + statusViewModel = StatusViewModel(statusService: statusService, identification: identification) + elements.statusIds.insert(statusService.status.displayStatus.id) + } else { + statusViewModel = nil + } + } +} + +public extension ReportViewModel { + enum Event { + case reported + } + + var accountName: String { "@".appending(accountService.account.acct) } + + var accountHost: String { + accountService.account.url.host ?? "" + } + + var isLocalAccount: Bool { accountService.isLocal } + + func report() { + accountService.report(elements) + .receive(on: DispatchQueue.main) + .handleEvents(receiveSubscription: { [weak self] _ in self?.loading = true }) + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .sink { [weak self] in + guard let self = self else { return } + + self.loading = false + + if $0 == .finished { + self.eventsSubject.send(.reported) + } + } receiveValue: { _ in } + .store(in: &cancellables) + } +} diff --git a/ViewModels/Sources/ViewModels/StatusViewModel.swift b/ViewModels/Sources/ViewModels/StatusViewModel.swift index a16f39d..665c0df 100644 --- a/ViewModels/Sources/ViewModels/StatusViewModel.swift +++ b/ViewModels/Sources/ViewModels/StatusViewModel.swift @@ -215,6 +215,17 @@ public extension StatusViewModel { eventsSubject.send(Just(.share(url)).setFailureType(to: Error.self).eraseToAnyPublisher()) } + func reportStatus() { + eventsSubject.send( + Just(.report(ReportViewModel( + accountService: statusService.navigationService.accountService( + account: statusService.status.displayStatus.account), + statusService: statusService, + identification: identification))) + .setFailureType(to: Error.self) + .eraseToAnyPublisher()) + } + func vote() { eventsSubject.send( statusService.vote(selectedOptions: pollOptionSelections) diff --git a/Views/ReportView.swift b/Views/ReportView.swift new file mode 100644 index 0000000..5aaef59 --- /dev/null +++ b/Views/ReportView.swift @@ -0,0 +1,101 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import SwiftUI +import ViewModels + +struct ReportView: View { + @StateObject var viewModel: ReportViewModel + + @Environment(\.presentationMode) private var presentationMode + fileprivate var dismissHostingController: (() -> Void)? + + var body: some View { + Form { + if let statusViewModel = viewModel.statusViewModel { + Section { + ReportStatusView(viewModel: statusViewModel) + .frame(height: Self.statusHeight) + } + } + Section { + Text("report.hint") + ZStack(alignment: .leading) { + if viewModel.elements.comment.isEmpty { + Text("report.placeholder").foregroundColor(.secondary) + } + TextEditor(text: $viewModel.elements.comment) + } + if !viewModel.isLocalAccount { + VStack(alignment: .leading) { + Text("report.forward.hint") + Toggle("report.forward-\(viewModel.accountHost)", isOn: $viewModel.elements.forward) + } + } + Group { + if viewModel.loading { + ProgressView() + } else { + Button("submit") { + viewModel.report() + } + } + } + } + } + .alertItem($viewModel.alertItem) + .onReceive(viewModel.events) { + switch $0 { + case .reported: + dismiss() + } + } + .navigationTitle("report.target-\(viewModel.accountName)") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + } + } + } + .navigationBarTitleDisplayMode(.inline) + } +} + +private extension ReportView { + static let statusHeight: CGFloat = 100 + + func dismiss() { + if let dismissHostingController = dismissHostingController { + dismissHostingController() + } else { + presentationMode.wrappedValue.dismiss() + } + } +} + +class ReportViewController: UIHostingController { + init(viewModel: ReportViewModel) { + super.init(rootView: ReportView(viewModel: viewModel)) + + rootView.dismissHostingController = { [weak self] in self?.dismiss(animated: true) } + } + + @available(*, unavailable) + @objc required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +#if DEBUG +import PreviewViewModels + +struct ReportView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + ReportView(viewModel: .preview) + } + } +} +#endif diff --git a/Views/Status/ReportStatusView.swift b/Views/Status/ReportStatusView.swift new file mode 100644 index 0000000..fb5338a --- /dev/null +++ b/Views/Status/ReportStatusView.swift @@ -0,0 +1,27 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import SwiftUI +import ViewModels + +struct ReportStatusView: UIViewRepresentable { + private let configuration: StatusContentConfiguration + + init(viewModel: StatusViewModel) { + configuration = StatusContentConfiguration(viewModel: viewModel) + } + + func makeUIView(context: Context) -> StatusView { + let view = StatusView(configuration: configuration) + + view.alpha = 0.5 + view.buttonsStackView.isHidden = true + view.translatesAutoresizingMaskIntoConstraints = false + view.isUserInteractionEnabled = false + + return view + } + + func updateUIView(_ uiView: StatusView, context: Context) { + + } +} diff --git a/Views/Status/StatusView.swift b/Views/Status/StatusView.swift index 7263652..d71ae35 100644 --- a/Views/Status/StatusView.swift +++ b/Views/Status/StatusView.swift @@ -23,6 +23,7 @@ final class StatusView: UIView { let favoriteButton = UIButton() let shareButton = UIButton() let menuButton = UIButton() + let buttonsStackView = UIStackView() private let containerStackView = UIStackView() private let sideStackView = UIStackView() @@ -35,7 +36,6 @@ final class StatusView: UIView { private let interactionsDividerView = UIView() private let interactionsStackView = UIStackView() private let buttonsDividerView = UIView() - private let buttonsStackView = UIStackView() private let inReplyToView = UIView() private let hasReplyFollowingView = UIView() private var statusConfiguration: StatusContentConfiguration @@ -205,6 +205,16 @@ private extension StatusView { UIAction { [weak self] _ in self?.statusConfiguration.viewModel.shareStatus() }, for: .touchUpInside) + menuButton.showsMenuAsPrimaryAction = true + menuButton.menu = UIMenu(children: [ + UIAction( + title: NSLocalizedString("report", comment: ""), + image: UIImage(systemName: "flag"), + attributes: .destructive) { [weak self] _ in + self?.statusConfiguration.viewModel.reportStatus() + } + ]) + for button in actionButtons { button.titleLabel?.font = .preferredFont(forTextStyle: .footnote) button.titleLabel?.adjustsFontSizeToFitWidth = true