From bd21e88e8bd1d453f9e9a13da475d59ba09f2bb9 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 28 Oct 2023 12:16:14 -0500 Subject: [PATCH] Add UI for changing list reply policy and exclusivity Closes #428 --- .../InstanceFeatures/InstanceFeatures.swift | 8 ++ Tusker.xcodeproj/project.pbxproj | 4 + Tusker/API/EditListSettingsService.swift | 46 ++++++++++ Tusker/API/MastodonController.swift | 2 +- Tusker/API/RenameListService.swift | 2 +- .../EditListAccountsViewController.swift | 91 +++++++++++++++++-- .../Lists/ListTimelineViewController.swift | 2 +- 7 files changed, 144 insertions(+), 11 deletions(-) create mode 100644 Tusker/API/EditListSettingsService.swift diff --git a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift index 9219f9aa..cfd35891 100644 --- a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift +++ b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift @@ -175,6 +175,14 @@ public class InstanceFeatures: ObservableObject { hasMastodonVersion(2, 8, 0) } + public var listRepliesPolicy: Bool { + hasMastodonVersion(3, 3, 0) + } + + public var exclusiveLists: Bool { + hasMastodonVersion(4, 2, 0) || instanceType.isMastodon(.hometown(nil)) + } + public init() { } diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index a15de80a..b3e129fc 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -264,6 +264,7 @@ D6BD395B29B64441005FFD2B /* ComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BD395A29B64441005FFD2B /* ComposeHostingController.swift */; }; D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */ = {isa = PBXBuildFile; productRef = D6BEA244291A0EDE002F4D01 /* Duckable */; }; D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */; }; + D6C041C42AED77730094D32D /* EditListSettingsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C041C32AED77730094D32D /* EditListSettingsService.swift */; }; D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; }; D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */; }; D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */; }; @@ -665,6 +666,7 @@ D6BD395C29B789D5005FFD2B /* TuskerComponents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerComponents; path = Packages/TuskerComponents; sourceTree = ""; }; D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = ""; }; D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = ""; }; + D6C041C32AED77730094D32D /* EditListSettingsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListSettingsService.swift; sourceTree = ""; }; D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = ""; }; D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPredicateStatusesViewController.swift; sourceTree = ""; }; D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = ""; }; @@ -1636,6 +1638,7 @@ D621733228F1D5ED004C7DB1 /* ReblogService.swift */, D6F6A54F291F058600F496A8 /* CreateListService.swift */, D6F6A551291F098700F496A8 /* RenameListService.swift */, + D6C041C32AED77730094D32D /* EditListSettingsService.swift */, D6F6A553291F0D9600F496A8 /* DeleteListService.swift */, D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */, D61F75B0293BD85300C0B37F /* CreateFilterService.swift */, @@ -2140,6 +2143,7 @@ D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */, D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */, D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */, + D6C041C42AED77730094D32D /* EditListSettingsService.swift in Sources */, D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */, D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */, D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */, diff --git a/Tusker/API/EditListSettingsService.swift b/Tusker/API/EditListSettingsService.swift new file mode 100644 index 00000000..27fa466a --- /dev/null +++ b/Tusker/API/EditListSettingsService.swift @@ -0,0 +1,46 @@ +// +// EditListSettingsService.swift +// Tusker +// +// Created by Shadowfacts on 10/28/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +@MainActor +class EditListSettingsService { + private let list: ListProtocol + private let mastodonController: MastodonController + private let present: (UIViewController) -> Void + + init(list: ListProtocol, mastodonController: MastodonController, present: @escaping (UIViewController) -> Void) { + self.list = list + self.mastodonController = mastodonController + self.present = present + } + + func run(title: String? = nil, replyPolicy: List.ReplyPolicy? = nil, exclusive: Bool? = nil) async { + do { + let req = List.update( + list.id, + title: title ?? list.title, + replyPolicy: replyPolicy ?? list.replyPolicy, + exclusive: exclusive ?? list.exclusive + ) + let (list, _) = try await mastodonController.run(req) + mastodonController.updatedList(list) + } catch { + let alert = UIAlertController(title: "Error Updating List", message: error.localizedDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Retry", style: .default, handler: { _ in + Task { + await self.run(title: title, replyPolicy: replyPolicy, exclusive: exclusive) + } + })) + present(alert) + } + } + +} diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index 5cb73fa1..1adf417e 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -471,7 +471,7 @@ class MastodonController: ObservableObject { } @MainActor - func renamedList(_ list: List) { + func updatedList(_ list: List) { var new = self.lists if let index = new.firstIndex(where: { $0.id == list.id }) { new[index] = list diff --git a/Tusker/API/RenameListService.swift b/Tusker/API/RenameListService.swift index 45931ceb..9b0ac9c4 100644 --- a/Tusker/API/RenameListService.swift +++ b/Tusker/API/RenameListService.swift @@ -49,7 +49,7 @@ class RenameListService { do { let req = List.update(list.id, title: title, replyPolicy: nil, exclusive: nil) let (list, _) = try await mastodonController.run(req) - mastodonController.renamedList(list) + mastodonController.updatedList(list) } catch { let alert = UIAlertController(title: "Error Updating List", message: error.localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) diff --git a/Tusker/Screens/Lists/EditListAccountsViewController.swift b/Tusker/Screens/Lists/EditListAccountsViewController.swift index b3327cd5..40f03354 100644 --- a/Tusker/Screens/Lists/EditListAccountsViewController.swift +++ b/Tusker/Screens/Lists/EditListAccountsViewController.swift @@ -38,7 +38,6 @@ class EditListAccountsViewController: UIViewController, CollectionViewController listRenamedCancellable = mastodonController.$lists .compactMap { $0.first { $0.id == list.id } } - .removeDuplicates(by: { $0.title == $1.title }) .sink { [unowned self] in self.list = $0 self.listChanged() @@ -103,9 +102,27 @@ class EditListAccountsViewController: UIViewController, CollectionViewController navigationItem.hidesSearchBarWhenScrolling = false if #available(iOS 16.0, *) { navigationItem.preferredSearchBarPlacement = .stacked + + navigationItem.renameDelegate = self + navigationItem.titleMenuProvider = { [unowned self] suggested in + var children = suggested + children.append(contentsOf: self.listSettingsMenuElements()) + return UIMenu(children: children) + } + } else { + navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Edit", menu: UIMenu(children: [ + // uncached so that menu always reflects the current state of the list + UIDeferredMenuElement.uncached({ [unowned self] elementHandler in + var elements = self.listSettingsMenuElements() + elements.insert(UIAction(title: "Rename…", image: UIImage(systemName: "pencil"), handler: { [unowned self] _ in + RenameListService(list: self.list, mastodonController: self.mastodonController, present: { + self.present($0, animated: true) + }).run() + }), at: 0) + elementHandler(elements) + }) + ])) } - - navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Rename", comment: "rename list button title"), style: .plain, target: self, action: #selector(renameButtonPressed)) } private func createDataSource() -> UICollectionViewDiffableDataSource { @@ -140,7 +157,31 @@ class EditListAccountsViewController: UIViewController, CollectionViewController } private func listChanged() { - title = String(format: NSLocalizedString("Edit %@", comment: "edit list screen title"), list.title) + title = list.title + } + + private func listSettingsMenuElements() -> [UIMenuElement] { + var elements = [UIMenuElement]() + if mastodonController.instanceFeatures.listRepliesPolicy { + let actions = List.ReplyPolicy.allCases.map { policy in + UIAction(title: policy.actionTitle, state: list.replyPolicy == policy ? .on : .off) { [unowned self] _ in + self.setReplyPolicy(policy) + } + } + elements.append(UIMenu(title: "Show replies…", image: UIImage(systemName: "arrowshape.turn.up.left"), children: actions)) + } + if mastodonController.instanceFeatures.exclusiveLists { + let actions = [ + UIAction(title: "Hidden from Home", state: list.exclusive == true ? .on : .off) { [unowned self] _ in + self.setExclusive(true) + }, + UIAction(title: "Shown on Home", state: list.exclusive == false ? .on : .off) { [unowned self] _ in + self.setExclusive(false) + }, + ] + elements.append(UIMenu(title: "Posts from this list are…", children: actions)) + } + return elements } @MainActor @@ -254,13 +295,21 @@ class EditListAccountsViewController: UIViewController, CollectionViewController self.showToast(configuration: config, animated: true) } } - - // MARK: - Interaction - @objc func renameButtonPressed() { - RenameListService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }).run() + private func setReplyPolicy(_ replyPolicy: List.ReplyPolicy) { + Task { + let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }) + await service.run(replyPolicy: replyPolicy) + } } + private func setExclusive(_ exclusive: Bool) { + Task { + let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }) + await service.run(exclusive: exclusive) + } + } + } extension EditListAccountsViewController { @@ -310,3 +359,29 @@ extension EditListAccountsViewController: SearchResultsViewControllerDelegate { } } } + +extension EditListAccountsViewController: UINavigationItemRenameDelegate { + func navigationItem(_: UINavigationItem, shouldEndRenamingWith title: String) -> Bool { + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + func navigationItem(_: UINavigationItem, didEndRenamingWith title: String) { + Task { + let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }) + await service.run(title: title) + } + } +} + +private extension List.ReplyPolicy { + var actionTitle: String { + switch self { + case .followed: + "To accounts you follow" + case .list: + "To other list members" + case .none: + "Never" + } + } +} diff --git a/Tusker/Screens/Lists/ListTimelineViewController.swift b/Tusker/Screens/Lists/ListTimelineViewController.swift index eab7d025..23ffd7d7 100644 --- a/Tusker/Screens/Lists/ListTimelineViewController.swift +++ b/Tusker/Screens/Lists/ListTimelineViewController.swift @@ -59,7 +59,7 @@ class ListTimelineViewController: TimelineViewController { func presentEdit(animated: Bool) { let editListAccountsController = EditListAccountsViewController(list: list, mastodonController: mastodonController) - editListAccountsController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed)) + editListAccountsController.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed)) let navController = UINavigationController(rootViewController: editListAccountsController) present(navController, animated: animated) }