diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index 2ec09a3..87ac517 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -6,6 +6,7 @@ "accessibility.copy-text" = "Copy text"; "account.%@-followers" = "%@'s Followers"; "account.accept-follow-request-button.accessibility-label" = "Accept follow request"; +"account.add-remove-lists" = "Add/remove from lists"; "account.avatar.accessibility-label-%@" = "Avatar: %@"; "account.block" = "Block"; "account.block-and-report" = "Block & report"; diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/EmptyEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/EmptyEndpoint.swift index cd5148b..db24bc7 100644 --- a/MastodonAPI/Sources/MastodonAPI/Endpoints/EmptyEndpoint.swift +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/EmptyEndpoint.swift @@ -6,6 +6,8 @@ import Mastodon public enum EmptyEndpoint { case oauthRevoke(token: String, clientId: String, clientSecret: String) + case addAccountsToList(id: List.Id, accountIds: Set) + case removeAccountsFromList(id: List.Id, accountIds: Set) case deleteList(id: List.Id) case deleteFilter(id: Filter.Id) case blockDomain(String) @@ -19,7 +21,7 @@ extension EmptyEndpoint: Endpoint { switch self { case .oauthRevoke: return ["oauth"] - case .deleteList: + case .addAccountsToList, .removeAccountsFromList, .deleteList: return defaultContext + ["lists"] case .deleteFilter: return defaultContext + ["filters"] @@ -32,6 +34,8 @@ extension EmptyEndpoint: Endpoint { switch self { case .oauthRevoke: return ["revoke"] + case let .addAccountsToList(id, _), let .removeAccountsFromList(id, _): + return [id, "accounts"] case let .deleteList(id), let .deleteFilter(id): return [id] case .blockDomain, .unblockDomain: @@ -41,9 +45,9 @@ extension EmptyEndpoint: Endpoint { public var method: HTTPMethod { switch self { - case .oauthRevoke, .blockDomain: + case .addAccountsToList, .oauthRevoke, .blockDomain: return .post - case .deleteList, .deleteFilter, .unblockDomain: + case .removeAccountsFromList, .deleteList, .deleteFilter, .unblockDomain: return .delete } } @@ -52,6 +56,8 @@ extension EmptyEndpoint: Endpoint { switch self { case let .oauthRevoke(token, clientId, clientSecret): return ["token": token, "client_id": clientId, "client_secret": clientSecret] + case let .addAccountsToList(_, accountIds), let .removeAccountsFromList(_, accountIds): + return ["account_ids": Array(accountIds)] case let .blockDomain(domain), let .unblockDomain(domain): return ["domain": domain] case .deleteList, .deleteFilter: diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/ListsEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/ListsEndpoint.swift index 5791ee0..2c47d04 100644 --- a/MastodonAPI/Sources/MastodonAPI/Endpoints/ListsEndpoint.swift +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/ListsEndpoint.swift @@ -6,13 +6,28 @@ import Mastodon public enum ListsEndpoint { case lists + case listsWithAccount(id: Account.Id) } extension ListsEndpoint: Endpoint { public typealias ResultType = [List] + public var context: [String] { + switch self { + case .lists: + return defaultContext + case .listsWithAccount: + return defaultContext + ["accounts"] + } + } + public var pathComponentsInContext: [String] { - ["lists"] + switch self { + case .lists: + return ["lists"] + case let .listsWithAccount(id): + return [id, "lists"] + } } public var method: HTTPMethod { diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 3b29658..ef522af 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -41,6 +41,7 @@ D025B16A25C4EB18001C69A8 /* ServiceLayer in Frameworks */ = {isa = PBXBuildFile; productRef = D025B16925C4EB18001C69A8 /* ServiceLayer */; }; D025B17E25C500BC001C69A8 /* CapsuleButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D025B17D25C500BC001C69A8 /* CapsuleButton.swift */; }; D02D338D25EDA593000A35CC /* CopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02D338C25EDA593000A35CC /* CopyableLabel.swift */; }; + D02D33EF25EE04CC000A35CC /* AddRemoveFromListsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02D33EE25EE04CC000A35CC /* AddRemoveFromListsView.swift */; }; D02E1F95250B13210071AD56 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02E1F94250B13210071AD56 /* SafariView.swift */; }; D035D8F925E4338D00E597C9 /* ImageDiskCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D035D8F825E4338D00E597C9 /* ImageDiskCache.swift */; }; D035D8FE25E4339800E597C9 /* ImageDiskCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D035D8F825E4338D00E597C9 /* ImageDiskCache.swift */; }; @@ -279,6 +280,7 @@ D025B14C25C4E482001C69A8 /* ImageCacheConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCacheConfiguration.swift; sourceTree = ""; }; D025B17D25C500BC001C69A8 /* CapsuleButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapsuleButton.swift; sourceTree = ""; }; D02D338C25EDA593000A35CC /* CopyableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLabel.swift; sourceTree = ""; }; + D02D33EE25EE04CC000A35CC /* AddRemoveFromListsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddRemoveFromListsView.swift; sourceTree = ""; }; D02E1F94250B13210071AD56 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; D035D8F825E4338D00E597C9 /* ImageDiskCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDiskCache.swift; sourceTree = ""; }; D035F86825B7F2ED00DC75ED /* MainNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainNavigationViewController.swift; sourceTree = ""; }; @@ -523,6 +525,7 @@ children = ( D021A62B25C38570008A0C0D /* AboutView.swift */, D021A63525C38ADB008A0C0D /* AcknowledgmentsView.swift */, + D02D33EE25EE04CC000A35CC /* AddRemoveFromListsView.swift */, D08E52602579D2E100FA2C5F /* DomainBlocksView.swift */, D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */, D0BEB20424FA1107001B0F04 /* FiltersView.swift */, @@ -1163,6 +1166,7 @@ D0A7AC7325748BFF00E4E8AB /* ReportStatusView.swift in Sources */, D0C7D4C324F7616A001EBDBB /* MetatextApp.swift in Sources */, D097F4C125BFA04C00859F2C /* NotificationsViewController.swift in Sources */, + D02D33EF25EE04CC000A35CC /* AddRemoveFromListsView.swift in Sources */, D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */, D0CEC0E125E0BB9700FEF5A6 /* NewItemsView.swift in Sources */, D0BEB20524FA1107001B0F04 /* FiltersView.swift in Sources */, diff --git a/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift b/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift index 5b3bfb3..1a00f03 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift @@ -32,6 +32,22 @@ public extension AccountService { var domain: String? { URL(string: account.url)?.host } + func lists() -> AnyPublisher<[List], Error> { + mastodonAPIClient.request(ListsEndpoint.listsWithAccount(id: account.id)) + } + + func addToList(id: List.Id) -> AnyPublisher { + mastodonAPIClient.request(EmptyEndpoint.addAccountsToList(id: id, accountIds: [account.id])) + .ignoreOutput() + .eraseToAnyPublisher() + } + + func removeFromList(id: List.Id) -> AnyPublisher { + mastodonAPIClient.request(EmptyEndpoint.removeAccountsFromList(id: id, accountIds: [account.id])) + .ignoreOutput() + .eraseToAnyPublisher() + } + func follow() -> AnyPublisher { relationshipAction(.accountsFollow(id: account.id)) } diff --git a/View Controllers/ProfileViewController.swift b/View Controllers/ProfileViewController.swift index a7339ff..53e8178 100644 --- a/View Controllers/ProfileViewController.swift +++ b/View Controllers/ProfileViewController.swift @@ -74,6 +74,11 @@ private extension ProfileViewController { // swiftlint:disable:next function_body_length func menu(accountViewModel: AccountViewModel, relationship: Relationship) -> UIMenu { var actions = [UIAction( + title: NSLocalizedString("account.add-remove-lists", comment: ""), + image: UIImage(systemName: "scroll")) { [weak self] _ in + self?.addRemoveFromLists(accountViewModel: accountViewModel) + }, + UIAction( title: NSLocalizedString("share", comment: ""), image: UIImage(systemName: "square.and.arrow.up")) { _ in accountViewModel.share() diff --git a/View Controllers/TableViewController.swift b/View Controllers/TableViewController.swift index 30384ce..3cd0f42 100644 --- a/View Controllers/TableViewController.swift +++ b/View Controllers/TableViewController.swift @@ -203,6 +203,13 @@ extension TableViewController { present(navigationController, animated: true) } + func addRemoveFromLists(accountViewModel: AccountViewModel) { + let addRemoveFromListsView = AddRemoveFromListsView(viewModel: .init(accountViewModel: accountViewModel)) + let addRemoveFromListsController = UIHostingController(rootView: addRemoveFromListsView) + + show(addRemoveFromListsController, sender: self) + } + func sizeTableHeaderFooterViews() { // https://useyourloaf.com/blog/variable-height-table-view-header/ if let headerView = tableView.tableHeaderView { diff --git a/ViewModels/Sources/ViewModels/View Models/AccountViewModel.swift b/ViewModels/Sources/ViewModels/View Models/AccountViewModel.swift index e6a6588..75bee39 100644 --- a/ViewModels/Sources/ViewModels/View Models/AccountViewModel.swift +++ b/ViewModels/Sources/ViewModels/View Models/AccountViewModel.swift @@ -103,6 +103,18 @@ public extension AccountViewModel { MuteViewModel(accountService: accountService, identityContext: identityContext) } + func lists() -> AnyPublisher<[List], Error> { + accountService.lists() + } + + func addToList(id: List.Id) -> AnyPublisher { + accountService.addToList(id: id) + } + + func removeFromList(id: List.Id) -> AnyPublisher { + accountService.removeFromList(id: id) + } + func follow() { ignorableOutputEvent(accountService.follow()) } diff --git a/ViewModels/Sources/ViewModels/View Models/AddRemoveFromListsViewModel.swift b/ViewModels/Sources/ViewModels/View Models/AddRemoveFromListsViewModel.swift new file mode 100644 index 0000000..e7f3149 --- /dev/null +++ b/ViewModels/Sources/ViewModels/View Models/AddRemoveFromListsViewModel.swift @@ -0,0 +1,65 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import Combine +import Foundation +import Mastodon + +final public class AddRemoveFromListsViewModel: ObservableObject { + public let accountViewModel: AccountViewModel + @Published public private(set) var lists = [List]() + @Published public private(set) var listIdsWithAccount = Set() + @Published public private(set) var loaded = false + @Published public var alertItem: AlertItem? + + private let listsViewModel: ListsViewModel + private var cancellables = Set() + + public init(accountViewModel: AccountViewModel) { + self.accountViewModel = accountViewModel + listsViewModel = ListsViewModel(identityContext: accountViewModel.identityContext) + + listsViewModel.$lists.assign(to: &$lists) + listsViewModel.$alertItem.assign(to: &$alertItem) + } +} + +public extension AddRemoveFromListsViewModel { + func refreshLists() { + listsViewModel.refreshLists() + } + + func fetchListsWithAccount() { + accountViewModel.lists() + .receive(on: DispatchQueue.main) + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .sink { [weak self] in + self?.listIdsWithAccount = Set($0.map(\.id)) + self?.loaded = true + } + .store(in: &cancellables) + } + + func addToList(id: List.Id) { + accountViewModel.addToList(id: id) + .receive(on: DispatchQueue.main) + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .sink { [weak self] in + if case .finished = $0 { + self?.listIdsWithAccount.insert(id) + } + } receiveValue: { _ in } + .store(in: &cancellables) + } + + func removeFromList(id: List.Id) { + accountViewModel.removeFromList(id: id) + .receive(on: DispatchQueue.main) + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .sink { [weak self] in + if case .finished = $0 { + self?.listIdsWithAccount.remove(id) + } + } receiveValue: { _ in } + .store(in: &cancellables) + } +} diff --git a/Views/SwiftUI/AddRemoveFromListsView.swift b/Views/SwiftUI/AddRemoveFromListsView.swift new file mode 100644 index 0000000..447767f --- /dev/null +++ b/Views/SwiftUI/AddRemoveFromListsView.swift @@ -0,0 +1,40 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import SwiftUI +import ViewModels + +struct AddRemoveFromListsView: View { + @StateObject var viewModel: AddRemoveFromListsViewModel + + var body: some View { + Group { + if viewModel.loaded { + List(viewModel.lists) { list in + Button { + if viewModel.listIdsWithAccount.contains(list.id) { + viewModel.removeFromList(id: list.id) + } else { + viewModel.addToList(id: list.id) + } + } label: { + HStack { + Text(list.title) + if viewModel.listIdsWithAccount.contains(list.id) { + Spacer() + Image(systemName: "checkmark.circle") + .foregroundColor(.green) + } + } + } + .accessibility(addTraits: viewModel.listIdsWithAccount.contains(list.id) ? [.isSelected] : []) + } + } else { + ProgressView() + } + } + .onAppear(perform: viewModel.refreshLists) + .onAppear(perform: viewModel.fetchListsWithAccount) + .navigationTitle(Text("secondary-navigation.lists")) + .alertItem($viewModel.alertItem) + } +}