Reporting

This commit is contained in:
Justin Mazzocchi 2020-11-29 18:54:11 -08:00
parent 6e7541f208
commit 579f64f089
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
16 changed files with 379 additions and 13 deletions

View file

@ -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";

View file

@ -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
}

View file

@ -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<Status.Id>()
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
}
}
}

View file

@ -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 = "<group>"; };
D08B8D8C2544E6EC00B1EBEF /* PollResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollResultView.swift; sourceTree = "<group>"; };
D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = "<group>"; };
D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportStatusView.swift; sourceTree = "<group>"; };
D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = "<group>"; };
D0B32F4F250B373600311912 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = "<group>"; };
D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileCollection+Extensions.swift"; sourceTree = "<group>"; };
@ -193,6 +196,7 @@
D0C7D46E24F76169001EBDBB /* KingfisherOptionsInfo+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "KingfisherOptionsInfo+Extensions.swift"; sourceTree = "<group>"; };
D0C7D46F24F76169001EBDBB /* View+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = "<group>"; };
D0D7C013250440610039AD6F /* CodableBloomFilter */ = {isa = PBXFileReference; lastKnownFileType = folder; path = CodableBloomFilter; sourceTree = "<group>"; };
D0DD50CA256B1F24004A04F7 /* ReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportView.swift; sourceTree = "<group>"; };
D0E0F1E424FC49FC002C04BF /* Mastodon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Mastodon; sourceTree = "<group>"; };
D0E1F582251F13EC00D45315 /* WebfingerIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebfingerIndicatorView.swift; sourceTree = "<group>"; };
D0E2C1CF24FD8BA400854680 /* ViewModels */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ViewModels; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -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 */,

View file

@ -0,0 +1,5 @@
// Copyright © 2020 Metabolist. All rights reserved.
import MastodonAPI
public typealias ReportElements = ReportEndpoint.Elements

View file

@ -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<Never, Error> {
relationshipAction(.accountsFollow(id: account.id))
}
@ -82,6 +86,10 @@ public extension AccountService {
func set(note: String) -> AnyPublisher<Never, Error> {
relationshipAction(.note(note, id: account.id))
}
func report(_ elements: ReportElements) -> AnyPublisher<Never, Error> {
mastodonAPIClient.request(ReportEndpoint.create(elements)).ignoreOutput().eraseToAnyPublisher()
}
}
private extension AccountService {

View file

@ -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: ""),

View file

@ -126,6 +126,13 @@ extension TableViewController {
static let autoplayableAttachmentsView = PassthroughSubject<StatusAttachmentsView?, Never>()
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)
}
}

View file

@ -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

View file

@ -66,6 +66,10 @@ public extension AccountViewModel {
.eraseToAnyPublisher())
}
func reportViewModel() -> ReportViewModel {
ReportViewModel(accountService: accountService, identification: identification)
}
func follow() {
ignorableOutputEvent(accountService.follow())
}

View file

@ -7,5 +7,6 @@ public enum CollectionItemEvent {
case ignorableOutput
case navigation(Navigation)
case attachment(AttachmentViewModel, StatusViewModel)
case report(ReportViewModel)
case share(URL)
}

View file

@ -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<Event, Never>
public let statusViewModel: StatusViewModel?
@Published public private(set) var loading = false
@Published public var alertItem: AlertItem?
private let accountService: AccountService
private let eventsSubject = PassthroughSubject<Event, Never>()
private var cancellables = Set<AnyCancellable>()
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)
}
}

View file

@ -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)

101
Views/ReportView.swift Normal file
View file

@ -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<ReportView> {
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

View file

@ -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) {
}
}

View file

@ -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