diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index 2a4d986..b153e48 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -2,6 +2,11 @@ "account.%@-followers" = "%@'s Followers"; "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.follow" = "Follow"; "account.following" = "Following"; @@ -15,6 +20,7 @@ "account.media" = "Media"; "account.show-reblogs" = "Show boosts"; "account.unblock" = "Unblock"; +"account.unblock.confirm-%@" = "Unblock %@?"; "account.unfollow" = "Unfollow"; "account.unmute" = "Unmute"; "add" = "Add"; @@ -53,6 +59,7 @@ "pending.pending-confirmation" = "Your account is pending confirmation"; "preferences" = "Preferences"; "preferences.app" = "App Preferences"; +"preferences.blocked-domains" = "Blocked Domains"; "preferences.blocked-users" = "Blocked Users"; "preferences.media" = "Media"; "preferences.media.use-system-reduce-motion" = "Use system reduce motion setting"; diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/EmptyEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/EmptyEndpoint.swift index 228b39c..cd5148b 100644 --- a/MastodonAPI/Sources/MastodonAPI/Endpoints/EmptyEndpoint.swift +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/EmptyEndpoint.swift @@ -8,6 +8,8 @@ public enum EmptyEndpoint { case oauthRevoke(token: String, clientId: String, clientSecret: String) case deleteList(id: List.Id) case deleteFilter(id: Filter.Id) + case blockDomain(String) + case unblockDomain(String) } extension EmptyEndpoint: Endpoint { @@ -21,6 +23,8 @@ extension EmptyEndpoint: Endpoint { return defaultContext + ["lists"] case .deleteFilter: return defaultContext + ["filters"] + case .blockDomain, .unblockDomain: + return defaultContext + ["domain_blocks"] } } @@ -30,14 +34,16 @@ extension EmptyEndpoint: Endpoint { return ["revoke"] case let .deleteList(id), let .deleteFilter(id): return [id] + case .blockDomain, .unblockDomain: + return [] } } public var method: HTTPMethod { switch self { - case .oauthRevoke: + case .oauthRevoke, .blockDomain: return .post - case .deleteList, .deleteFilter: + case .deleteList, .deleteFilter, .unblockDomain: return .delete } } @@ -46,6 +52,8 @@ extension EmptyEndpoint: Endpoint { switch self { case let .oauthRevoke(token, clientId, clientSecret): return ["token": token, "client_id": clientId, "client_secret": clientSecret] + case let .blockDomain(domain), let .unblockDomain(domain): + return ["domain": domain] case .deleteList, .deleteFilter: return nil } diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/StringsEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/StringsEndpoint.swift new file mode 100644 index 0000000..da04f5d --- /dev/null +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/StringsEndpoint.swift @@ -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 + } + } +} diff --git a/MastodonAPI/Sources/MastodonAPIStubs/StringsEndpoint+Stubbing.swift b/MastodonAPI/Sources/MastodonAPIStubs/StringsEndpoint+Stubbing.swift new file mode 100644 index 0000000..fe98593 --- /dev/null +++ b/MastodonAPI/Sources/MastodonAPIStubs/StringsEndpoint+Stubbing.swift @@ -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"]) + } +} diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index d031014..82238ba 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 */; }; 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 */; }; D0A7AC7325748BFF00E4E8AB /* ReportStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A7AC7225748BFF00E4E8AB /* ReportStatusView.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 = ""; }; D08B8D8C2544E6EC00B1EBEF /* PollResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollResultView.swift; sourceTree = ""; }; D08E512025786A6600FA2C5F /* UIButton+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIButton+Extensions.swift"; sourceTree = ""; }; + D08E52602579D2E100FA2C5F /* DomainBlocksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainBlocksView.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 = ""; }; @@ -361,6 +363,7 @@ D00702282555E51200F38136 /* ConversationListCell.swift */, D00702302555F4AE00F38136 /* ConversationView.swift */, D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */, + D08E52602579D2E100FA2C5F /* DomainBlocksView.swift */, D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */, D0BEB20424FA1107001B0F04 /* FiltersView.swift */, D0C7D42224F76169001EBDBB /* IdentitiesView.swift */, @@ -684,6 +687,7 @@ D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */, D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */, D0B8510C25259E56004E0744 /* LoadMoreCell.swift in Sources */, + D08E52612579D2E100FA2C5F /* DomainBlocksView.swift in Sources */, D01F41E424F8889700D55A2D /* StatusAttachmentsView.swift in Sources */, D00702312555F4AE00F38136 /* ConversationView.swift in Sources */, D0BEB21124FA2A91001B0F04 /* EditFilterView.swift in Sources */, diff --git a/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift b/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift index d86aec0..d78af80 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift @@ -34,6 +34,8 @@ public extension AccountService { account.url.host == mastodonAPIClient.instanceURL.host } + var domain: String? { account.url.host } + func follow() -> AnyPublisher { relationshipAction(.accountsFollow(id: account.id)) } @@ -91,6 +93,18 @@ public extension AccountService { mastodonAPIClient.request(ReportEndpoint.create(elements)).ignoreOutput().eraseToAnyPublisher() } + func domainBlock() -> AnyPublisher { + guard let domain = domain else { return Fail(error: URLError(.badURL)).eraseToAnyPublisher() } + + return domainAction(EmptyEndpoint.blockDomain(domain)) + } + + func domainUnblock() -> AnyPublisher { + guard let domain = domain else { return Fail(error: URLError(.badURL)).eraseToAnyPublisher() } + + return domainAction(EmptyEndpoint.unblockDomain(domain)) + } + func followingService() -> AccountListService { AccountListService( endpoint: .accountsFollowing(id: account.id), @@ -114,4 +128,12 @@ private extension AccountService { .flatMap { contentDatabase.insert(relationships: [$0]) } .eraseToAnyPublisher() } + + func domainAction(_ endpoint: EmptyEndpoint) -> AnyPublisher { + mastodonAPIClient.request(endpoint) + .flatMap { _ in mastodonAPIClient.request(RelationshipsEndpoint.relationships(ids: [account.id])) } + .flatMap { contentDatabase.insert(relationships: $0) } + .ignoreOutput() + .eraseToAnyPublisher() + } } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/DomainBlocksService.swift b/ServiceLayer/Sources/ServiceLayer/Services/DomainBlocksService.swift new file mode 100644 index 0000000..c42869e --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Services/DomainBlocksService.swift @@ -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 + + private let mastodonAPIClient: MastodonAPIClient + private let nextPageMaxIdSubject = PassthroughSubject() + + 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 { + mastodonAPIClient.request(EmptyEndpoint.unblockDomain(domain)).ignoreOutput().eraseToAnyPublisher() + } +} diff --git a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift index 42eb0a5..46842e6 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift @@ -224,6 +224,10 @@ public extension IdentityService { func conversationsService() -> ConversationsService { ConversationsService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) } + + func domainBlocksService() -> DomainBlocksService { + DomainBlocksService(mastodonAPIClient: mastodonAPIClient) + } } private extension IdentityService { diff --git a/View Controllers/ProfileViewController.swift b/View Controllers/ProfileViewController.swift index 019426b..5b1bd63 100644 --- a/View Controllers/ProfileViewController.swift +++ b/View Controllers/ProfileViewController.swift @@ -114,18 +114,71 @@ private extension ProfileViewController { if relationship.blocking { actions.append(UIAction( title: NSLocalizedString("account.unblock", comment: ""), - image: UIImage(systemName: "slash.circle")) { _ in - accountViewModel.unblock() - }) + image: UIImage(systemName: "slash.circle"), + attributes: .destructive) { [weak self] _ in + self?.confirm(message: String.localizedStringWithFormat( + NSLocalizedString("account.unblock.confirm-%@", comment: ""), + accountViewModel.accountName)) { + accountViewModel.unblock() + } + }) } else { actions.append(UIAction( title: NSLocalizedString("account.block", comment: ""), image: UIImage(systemName: "slash.circle"), - attributes: .destructive) { _ in - accountViewModel.block() - }) + attributes: .destructive) { [weak self] _ in + 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) } + + 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) + } } diff --git a/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift b/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift index 6dd1cd2..f517cd1 100644 --- a/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift +++ b/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift @@ -86,4 +86,8 @@ public extension ReportViewModel { identification: .preview) } +public extension DomainBlocksViewModel { + static let preview = DomainBlocksViewModel(service: .init(mastodonAPIClient: .preview)) +} + // swiftlint:enable force_try diff --git a/ViewModels/Sources/ViewModels/AccountViewModel.swift b/ViewModels/Sources/ViewModels/AccountViewModel.swift index ffd0b1b..2e759e9 100644 --- a/ViewModels/Sources/ViewModels/AccountViewModel.swift +++ b/ViewModels/Sources/ViewModels/AccountViewModel.swift @@ -28,6 +28,10 @@ public extension AccountViewModel { } } + var isLocal: Bool { accountService.isLocal } + + var domain: String? { accountService.domain } + var displayName: String { accountService.account.displayName.isEmpty ? accountService.account.acct : accountService.account.displayName } @@ -131,6 +135,14 @@ public extension AccountViewModel { func set(note: String) { ignorableOutputEvent(accountService.set(note: note)) } + + func domainBlock() { + ignorableOutputEvent(accountService.domainBlock()) + } + + func domainUnblock() { + ignorableOutputEvent(accountService.domainUnblock()) + } } private extension AccountViewModel { diff --git a/ViewModels/Sources/ViewModels/DomainBlocksViewModel.swift b/ViewModels/Sources/ViewModels/DomainBlocksViewModel.swift new file mode 100644 index 0000000..e200290 --- /dev/null +++ b/ViewModels/Sources/ViewModels/DomainBlocksViewModel.swift @@ -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() + + 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) + } +} diff --git a/ViewModels/Sources/ViewModels/PreferencesViewModel.swift b/ViewModels/Sources/ViewModels/PreferencesViewModel.swift index c75d0ca..712d2d5 100644 --- a/ViewModels/Sources/ViewModels/PreferencesViewModel.swift +++ b/ViewModels/Sources/ViewModels/PreferencesViewModel.swift @@ -29,4 +29,8 @@ public extension PreferencesViewModel { collectionService: identification.service.service(accountList: .blocks), identification: identification) } + + func domainBlocksViewModel() -> DomainBlocksViewModel { + DomainBlocksViewModel(service: identification.service.domainBlocksService()) + } } diff --git a/Views/DomainBlocksView.swift b/Views/DomainBlocksView.swift new file mode 100644 index 0000000..ee4ac9d --- /dev/null +++ b/Views/DomainBlocksView.swift @@ -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 diff --git a/Views/PreferencesView.swift b/Views/PreferencesView.swift index f1ea96b..5a5feb0 100644 --- a/Views/PreferencesView.swift +++ b/Views/PreferencesView.swift @@ -27,6 +27,8 @@ struct PreferencesView: View { NavigationLink("preferences.blocked-users", destination: TableView(viewModelClosure: viewModel.blockedUsersViewModel) .navigationTitle(Text("preferences.blocked-users"))) + NavigationLink("preferences.blocked-domains", + destination: DomainBlocksView(viewModel: viewModel.domainBlocksViewModel())) } Section(header: Text("preferences.app")) { NavigationLink("preferences.media",