Domain blocks

This commit is contained in:
Justin Mazzocchi 2020-12-03 19:13:18 -08:00
parent 30cedb503f
commit 43e58bce35
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
15 changed files with 305 additions and 8 deletions

View file

@ -2,6 +2,11 @@
"account.%@-followers" = "%@'s Followers"; "account.%@-followers" = "%@'s Followers";
"account.block" = "Block"; "account.block" = "Block";
"account.block.confirm-%@" = "Block %@?";
"account.domain-block-%@" = "Block domain %@";
"account.domain-block.confirm-%@" = "Block domain %@?";
"account.domain-unblock-%@" = "Unblock domain %@";
"account.domain-unblock.confirm-%@" = "Unblock domain %@?";
"account.field.verified" = "Verified %@"; "account.field.verified" = "Verified %@";
"account.follow" = "Follow"; "account.follow" = "Follow";
"account.following" = "Following"; "account.following" = "Following";
@ -15,6 +20,7 @@
"account.media" = "Media"; "account.media" = "Media";
"account.show-reblogs" = "Show boosts"; "account.show-reblogs" = "Show boosts";
"account.unblock" = "Unblock"; "account.unblock" = "Unblock";
"account.unblock.confirm-%@" = "Unblock %@?";
"account.unfollow" = "Unfollow"; "account.unfollow" = "Unfollow";
"account.unmute" = "Unmute"; "account.unmute" = "Unmute";
"add" = "Add"; "add" = "Add";
@ -53,6 +59,7 @@
"pending.pending-confirmation" = "Your account is pending confirmation"; "pending.pending-confirmation" = "Your account is pending confirmation";
"preferences" = "Preferences"; "preferences" = "Preferences";
"preferences.app" = "App Preferences"; "preferences.app" = "App Preferences";
"preferences.blocked-domains" = "Blocked Domains";
"preferences.blocked-users" = "Blocked Users"; "preferences.blocked-users" = "Blocked Users";
"preferences.media" = "Media"; "preferences.media" = "Media";
"preferences.media.use-system-reduce-motion" = "Use system reduce motion setting"; "preferences.media.use-system-reduce-motion" = "Use system reduce motion setting";

View file

@ -8,6 +8,8 @@ public enum EmptyEndpoint {
case oauthRevoke(token: String, clientId: String, clientSecret: String) case oauthRevoke(token: String, clientId: String, clientSecret: String)
case deleteList(id: List.Id) case deleteList(id: List.Id)
case deleteFilter(id: Filter.Id) case deleteFilter(id: Filter.Id)
case blockDomain(String)
case unblockDomain(String)
} }
extension EmptyEndpoint: Endpoint { extension EmptyEndpoint: Endpoint {
@ -21,6 +23,8 @@ extension EmptyEndpoint: Endpoint {
return defaultContext + ["lists"] return defaultContext + ["lists"]
case .deleteFilter: case .deleteFilter:
return defaultContext + ["filters"] return defaultContext + ["filters"]
case .blockDomain, .unblockDomain:
return defaultContext + ["domain_blocks"]
} }
} }
@ -30,14 +34,16 @@ extension EmptyEndpoint: Endpoint {
return ["revoke"] return ["revoke"]
case let .deleteList(id), let .deleteFilter(id): case let .deleteList(id), let .deleteFilter(id):
return [id] return [id]
case .blockDomain, .unblockDomain:
return []
} }
} }
public var method: HTTPMethod { public var method: HTTPMethod {
switch self { switch self {
case .oauthRevoke: case .oauthRevoke, .blockDomain:
return .post return .post
case .deleteList, .deleteFilter: case .deleteList, .deleteFilter, .unblockDomain:
return .delete return .delete
} }
} }
@ -46,6 +52,8 @@ extension EmptyEndpoint: Endpoint {
switch self { switch self {
case let .oauthRevoke(token, clientId, clientSecret): case let .oauthRevoke(token, clientId, clientSecret):
return ["token": token, "client_id": clientId, "client_secret": clientSecret] return ["token": token, "client_id": clientId, "client_secret": clientSecret]
case let .blockDomain(domain), let .unblockDomain(domain):
return ["domain": domain]
case .deleteList, .deleteFilter: case .deleteList, .deleteFilter:
return nil return nil
} }

View file

@ -0,0 +1,27 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import HTTP
import Mastodon
public enum StringsEndpoint {
case domainBlocks
}
extension StringsEndpoint: Endpoint {
public typealias ResultType = [String]
public var pathComponentsInContext: [String] {
switch self {
case .domainBlocks:
return ["domain_blocks"]
}
}
public var method: HTTPMethod {
switch self {
case .domainBlocks:
return .get
}
}
}

View file

@ -0,0 +1,11 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import MastodonAPI
import Stubbing
extension StringsEndpoint: Stubbing {
public func data(url: URL) -> Data? {
try? JSONSerialization.data(withJSONObject: ["ok.lol"])
}
}

View file

@ -40,6 +40,7 @@
D08B8D822544D80000B1EBEF /* PollOptionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D812544D80000B1EBEF /* PollOptionButton.swift */; }; D08B8D822544D80000B1EBEF /* PollOptionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D812544D80000B1EBEF /* PollOptionButton.swift */; };
D08B8D8D2544E6EC00B1EBEF /* PollResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D8C2544E6EC00B1EBEF /* PollResultView.swift */; }; D08B8D8D2544E6EC00B1EBEF /* PollResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D8C2544E6EC00B1EBEF /* PollResultView.swift */; };
D08E512125786A6600FA2C5F /* UIButton+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E512025786A6600FA2C5F /* UIButton+Extensions.swift */; }; D08E512125786A6600FA2C5F /* UIButton+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E512025786A6600FA2C5F /* UIButton+Extensions.swift */; };
D08E52612579D2E100FA2C5F /* DomainBlocksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52602579D2E100FA2C5F /* DomainBlocksView.swift */; };
D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */; }; D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */; };
D0A7AC7325748BFF00E4E8AB /* ReportStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */; }; D0A7AC7325748BFF00E4E8AB /* ReportStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */; };
D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; }; D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; };
@ -159,6 +160,7 @@
D08B8D812544D80000B1EBEF /* PollOptionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionButton.swift; sourceTree = "<group>"; }; 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>"; }; D08B8D8C2544E6EC00B1EBEF /* PollResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollResultView.swift; sourceTree = "<group>"; };
D08E512025786A6600FA2C5F /* UIButton+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIButton+Extensions.swift"; sourceTree = "<group>"; }; D08E512025786A6600FA2C5F /* UIButton+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIButton+Extensions.swift"; sourceTree = "<group>"; };
D08E52602579D2E100FA2C5F /* DomainBlocksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainBlocksView.swift; sourceTree = "<group>"; };
D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.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>"; }; D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportStatusView.swift; sourceTree = "<group>"; };
D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = "<group>"; }; D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = "<group>"; };
@ -361,6 +363,7 @@
D00702282555E51200F38136 /* ConversationListCell.swift */, D00702282555E51200F38136 /* ConversationListCell.swift */,
D00702302555F4AE00F38136 /* ConversationView.swift */, D00702302555F4AE00F38136 /* ConversationView.swift */,
D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */, D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */,
D08E52602579D2E100FA2C5F /* DomainBlocksView.swift */,
D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */, D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */,
D0BEB20424FA1107001B0F04 /* FiltersView.swift */, D0BEB20424FA1107001B0F04 /* FiltersView.swift */,
D0C7D42224F76169001EBDBB /* IdentitiesView.swift */, D0C7D42224F76169001EBDBB /* IdentitiesView.swift */,
@ -684,6 +687,7 @@
D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */, D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */,
D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */, D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */,
D0B8510C25259E56004E0744 /* LoadMoreCell.swift in Sources */, D0B8510C25259E56004E0744 /* LoadMoreCell.swift in Sources */,
D08E52612579D2E100FA2C5F /* DomainBlocksView.swift in Sources */,
D01F41E424F8889700D55A2D /* StatusAttachmentsView.swift in Sources */, D01F41E424F8889700D55A2D /* StatusAttachmentsView.swift in Sources */,
D00702312555F4AE00F38136 /* ConversationView.swift in Sources */, D00702312555F4AE00F38136 /* ConversationView.swift in Sources */,
D0BEB21124FA2A91001B0F04 /* EditFilterView.swift in Sources */, D0BEB21124FA2A91001B0F04 /* EditFilterView.swift in Sources */,

View file

@ -34,6 +34,8 @@ public extension AccountService {
account.url.host == mastodonAPIClient.instanceURL.host account.url.host == mastodonAPIClient.instanceURL.host
} }
var domain: String? { account.url.host }
func follow() -> AnyPublisher<Never, Error> { func follow() -> AnyPublisher<Never, Error> {
relationshipAction(.accountsFollow(id: account.id)) relationshipAction(.accountsFollow(id: account.id))
} }
@ -91,6 +93,18 @@ public extension AccountService {
mastodonAPIClient.request(ReportEndpoint.create(elements)).ignoreOutput().eraseToAnyPublisher() mastodonAPIClient.request(ReportEndpoint.create(elements)).ignoreOutput().eraseToAnyPublisher()
} }
func domainBlock() -> AnyPublisher<Never, Error> {
guard let domain = domain else { return Fail(error: URLError(.badURL)).eraseToAnyPublisher() }
return domainAction(EmptyEndpoint.blockDomain(domain))
}
func domainUnblock() -> AnyPublisher<Never, Error> {
guard let domain = domain else { return Fail(error: URLError(.badURL)).eraseToAnyPublisher() }
return domainAction(EmptyEndpoint.unblockDomain(domain))
}
func followingService() -> AccountListService { func followingService() -> AccountListService {
AccountListService( AccountListService(
endpoint: .accountsFollowing(id: account.id), endpoint: .accountsFollowing(id: account.id),
@ -114,4 +128,12 @@ private extension AccountService {
.flatMap { contentDatabase.insert(relationships: [$0]) } .flatMap { contentDatabase.insert(relationships: [$0]) }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func domainAction(_ endpoint: EmptyEndpoint) -> AnyPublisher<Never, Error> {
mastodonAPIClient.request(endpoint)
.flatMap { _ in mastodonAPIClient.request(RelationshipsEndpoint.relationships(ids: [account.id])) }
.flatMap { contentDatabase.insert(relationships: $0) }
.ignoreOutput()
.eraseToAnyPublisher()
}
} }

View file

@ -0,0 +1,36 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import DB
import Foundation
import Mastodon
import MastodonAPI
public struct DomainBlocksService {
public let nextPageMaxId: AnyPublisher<String, Never>
private let mastodonAPIClient: MastodonAPIClient
private let nextPageMaxIdSubject = PassthroughSubject<String, Never>()
public init(mastodonAPIClient: MastodonAPIClient) {
self.mastodonAPIClient = mastodonAPIClient
nextPageMaxId = nextPageMaxIdSubject.eraseToAnyPublisher()
}
}
public extension DomainBlocksService {
func request(maxId: String?) -> AnyPublisher<[String], Error> {
mastodonAPIClient.pagedRequest(StringsEndpoint.domainBlocks, maxId: maxId)
.handleEvents(receiveOutput: {
if let maxId = $0.info.maxId {
nextPageMaxIdSubject.send(maxId)
}
})
.map(\.result)
.eraseToAnyPublisher()
}
func delete(domain: String) -> AnyPublisher<Never, Error> {
mastodonAPIClient.request(EmptyEndpoint.unblockDomain(domain)).ignoreOutput().eraseToAnyPublisher()
}
}

View file

@ -224,6 +224,10 @@ public extension IdentityService {
func conversationsService() -> ConversationsService { func conversationsService() -> ConversationsService {
ConversationsService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) ConversationsService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
} }
func domainBlocksService() -> DomainBlocksService {
DomainBlocksService(mastodonAPIClient: mastodonAPIClient)
}
} }
private extension IdentityService { private extension IdentityService {

View file

@ -114,18 +114,71 @@ private extension ProfileViewController {
if relationship.blocking { if relationship.blocking {
actions.append(UIAction( actions.append(UIAction(
title: NSLocalizedString("account.unblock", comment: ""), title: NSLocalizedString("account.unblock", comment: ""),
image: UIImage(systemName: "slash.circle")) { _ in image: UIImage(systemName: "slash.circle"),
accountViewModel.unblock() attributes: .destructive) { [weak self] _ in
}) self?.confirm(message: String.localizedStringWithFormat(
NSLocalizedString("account.unblock.confirm-%@", comment: ""),
accountViewModel.accountName)) {
accountViewModel.unblock()
}
})
} else { } else {
actions.append(UIAction( actions.append(UIAction(
title: NSLocalizedString("account.block", comment: ""), title: NSLocalizedString("account.block", comment: ""),
image: UIImage(systemName: "slash.circle"), image: UIImage(systemName: "slash.circle"),
attributes: .destructive) { _ in attributes: .destructive) { [weak self] _ in
accountViewModel.block() self?.confirm(message: String.localizedStringWithFormat(
}) NSLocalizedString("account.block.confirm-%@", comment: ""),
accountViewModel.accountName)) {
accountViewModel.block()
}
})
}
if !accountViewModel.isLocal, let domain = accountViewModel.domain {
if relationship.domainBlocking {
actions.append(UIAction(
title: String.localizedStringWithFormat(
NSLocalizedString("account.domain-unblock-%@", comment: ""),
domain),
image: UIImage(systemName: "slash.circle"),
attributes: .destructive) { [weak self] _ in
self?.confirm(message: String.localizedStringWithFormat(
NSLocalizedString("account.domain-unblock.confirm-%@", comment: ""),
domain)) {
accountViewModel.domainUnblock()
}
})
} else {
actions.append(UIAction(
title: String.localizedStringWithFormat(
NSLocalizedString("account.domain-block-%@", comment: ""),
domain),
image: UIImage(systemName: "slash.circle"),
attributes: .destructive) { [weak self] _ in
self?.confirm(message: String.localizedStringWithFormat(
NSLocalizedString("account.domain-block.confirm-%@", comment: ""),
domain)) {
accountViewModel.domainBlock()
}
})
}
} }
return UIMenu(children: actions) return UIMenu(children: actions)
} }
func confirm(message: String, action: @escaping () -> Void) {
let alertController = UIAlertController(title: nil, message: message, preferredStyle: .alert)
let cancelAction = UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .cancel, handler: nil)
let okAction = UIAlertAction(title: NSLocalizedString("ok", comment: ""), style: .destructive) { _ in
action()
}
alertController.addAction(cancelAction)
alertController.addAction(okAction)
present(alertController, animated: true)
}
} }

View file

@ -86,4 +86,8 @@ public extension ReportViewModel {
identification: .preview) identification: .preview)
} }
public extension DomainBlocksViewModel {
static let preview = DomainBlocksViewModel(service: .init(mastodonAPIClient: .preview))
}
// swiftlint:enable force_try // swiftlint:enable force_try

View file

@ -28,6 +28,10 @@ public extension AccountViewModel {
} }
} }
var isLocal: Bool { accountService.isLocal }
var domain: String? { accountService.domain }
var displayName: String { var displayName: String {
accountService.account.displayName.isEmpty ? accountService.account.acct : accountService.account.displayName accountService.account.displayName.isEmpty ? accountService.account.acct : accountService.account.displayName
} }
@ -131,6 +135,14 @@ public extension AccountViewModel {
func set(note: String) { func set(note: String) {
ignorableOutputEvent(accountService.set(note: note)) ignorableOutputEvent(accountService.set(note: note))
} }
func domainBlock() {
ignorableOutputEvent(accountService.domainBlock())
}
func domainUnblock() {
ignorableOutputEvent(accountService.domainUnblock())
}
} }
private extension AccountViewModel { private extension AccountViewModel {

View file

@ -0,0 +1,53 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import Foundation
import Mastodon
import ServiceLayer
public final class DomainBlocksViewModel: ObservableObject {
@Published public private(set) var domainBlocks = [String]()
@Published public var alertItem: AlertItem?
@Published public private(set) var loading = false
private let service: DomainBlocksService
private var nextPageMaxId: String?
private var cancellables = Set<AnyCancellable>()
public init(service: DomainBlocksService) {
self.service = service
service.nextPageMaxId
.sink { [weak self] in self?.nextPageMaxId = $0 }
.store(in: &cancellables)
}
}
public extension DomainBlocksViewModel {
func request() {
service.request(maxId: nextPageMaxId)
.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
self.domainBlocks.append(contentsOf: Set($0).subtracting(Set(self.domainBlocks)))
}
.store(in: &cancellables)
}
func delete(domain: String) {
service.delete(domain: domain)
.collect()
.receive(on: DispatchQueue.main)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { [weak self] in
if case .finished = $0 {
self?.domainBlocks.removeAll { $0 == domain }
}
} receiveValue: { _ in }
.store(in: &cancellables)
}
}

View file

@ -29,4 +29,8 @@ public extension PreferencesViewModel {
collectionService: identification.service.service(accountList: .blocks), collectionService: identification.service.service(accountList: .blocks),
identification: identification) identification: identification)
} }
func domainBlocksViewModel() -> DomainBlocksViewModel {
DomainBlocksViewModel(service: identification.service.domainBlocksService())
}
} }

View file

@ -0,0 +1,50 @@
// Copyright © 2020 Metabolist. All rights reserved.
import SwiftUI
import ViewModels
struct DomainBlocksView: View {
@StateObject var viewModel: DomainBlocksViewModel
var body: some View {
Form {
ForEach(viewModel.domainBlocks, id: \.self) { domain in
Text(domain)
.onAppear {
if domain == viewModel.domainBlocks.last {
viewModel.request()
}
}
}
.onDelete {
guard let index = $0.first else { return }
viewModel.delete(domain: viewModel.domainBlocks[index])
}
if viewModel.loading {
ProgressView()
}
}
.onAppear {
viewModel.request()
}
.navigationTitle("preferences.blocked-domains")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: ToolbarItemPlacement.navigationBarTrailing) {
EditButton()
}
}
}
}
#if DEBUG
import PreviewViewModels
struct DomainBlocksView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
DomainBlocksView(viewModel: .preview)
}
}
}
#endif

View file

@ -27,6 +27,8 @@ struct PreferencesView: View {
NavigationLink("preferences.blocked-users", NavigationLink("preferences.blocked-users",
destination: TableView(viewModelClosure: viewModel.blockedUsersViewModel) destination: TableView(viewModelClosure: viewModel.blockedUsersViewModel)
.navigationTitle(Text("preferences.blocked-users"))) .navigationTitle(Text("preferences.blocked-users")))
NavigationLink("preferences.blocked-domains",
destination: DomainBlocksView(viewModel: viewModel.domainBlocksViewModel()))
} }
Section(header: Text("preferences.app")) { Section(header: Text("preferences.app")) {
NavigationLink("preferences.media", NavigationLink("preferences.media",