From 16a1e4008bdbd0a32f776d754b94944434ccd085 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 3 Dec 2022 12:29:07 -0500 Subject: [PATCH] V2 filters API, CoreData, and editing UI --- Pachyderm/Sources/Pachyderm/Client.swift | 16 ++-- .../Model/{Filter.swift => FilterV1.swift} | 21 ++-- .../Sources/Pachyderm/Model/FilterV2.swift | 91 ++++++++++++++++++ .../Model/Protocols/FilterProtocol.swift | 17 ---- Tusker.xcodeproj/project.pbxproj | 16 +++- Tusker/API/InstanceFeatures.swift | 16 ++-- Tusker/API/MastodonController.swift | 17 +++- Tusker/CoreData/FilterKeywordMO.swift | 38 ++++++++ Tusker/CoreData/FilterMO.swift | 96 ++++++++++++++++--- .../MastodonCachePersistentStore.swift | 13 ++- .../Tusker.xcdatamodel/contents | 10 +- ...ext+Helpers.swift => Filter+Helpers.swift} | 15 ++- Tusker/Models/EditedFilter.swift | 38 ++++++++ Tusker/Screens/Filters/EditFilterView.swift | 75 +++++++++++---- Tusker/Screens/Filters/FilterRow.swift | 12 +-- Tusker/Screens/Filters/FiltersView.swift | 53 +++++++--- .../Timeline/TimelineViewController.swift | 4 + 17 files changed, 438 insertions(+), 110 deletions(-) rename Pachyderm/Sources/Pachyderm/Model/{Filter.swift => FilterV1.swift} (58%) create mode 100644 Pachyderm/Sources/Pachyderm/Model/FilterV2.swift delete mode 100644 Pachyderm/Sources/Pachyderm/Model/Protocols/FilterProtocol.swift create mode 100644 Tusker/CoreData/FilterKeywordMO.swift rename Tusker/Extensions/{FilterContext+Helpers.swift => Filter+Helpers.swift} (66%) create mode 100644 Tusker/Models/EditedFilter.swift diff --git a/Pachyderm/Sources/Pachyderm/Client.swift b/Pachyderm/Sources/Pachyderm/Client.swift index 0316b2f7..847d0f4d 100644 --- a/Pachyderm/Sources/Pachyderm/Client.swift +++ b/Pachyderm/Sources/Pachyderm/Client.swift @@ -229,12 +229,12 @@ public class Client { } // MARK: - Filters - public static func getFilters() -> Request<[Filter]> { - return Request<[Filter]>(method: .get, path: "/api/v1/filters") + public static func getFiltersV1() -> Request<[FilterV1]> { + return Request<[FilterV1]>(method: .get, path: "/api/v1/filters") } - public static func createFilter(phrase: String, context: [Filter.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request { - return Request(method: .post, path: "/api/v1/filters", body: ParametersBody([ + public static func createFilterV1(phrase: String, context: [FilterV1.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request { + return Request(method: .post, path: "/api/v1/filters", body: ParametersBody([ "phrase" => phrase, "irreversible" => irreversible, "whole_word" => wholeWord, @@ -242,8 +242,12 @@ public class Client { ] + "context" => context.contextStrings)) } - public static func getFilter(id: String) -> Request { - return Request(method: .get, path: "/api/v1/filters/\(id)") + public static func getFilterV1(id: String) -> Request { + return Request(method: .get, path: "/api/v1/filters/\(id)") + } + + public static func getFiltersV2() -> Request<[FilterV2]> { + return Request(method: .get, path: "/api/v2/filters") } // MARK: - Follows diff --git a/Pachyderm/Sources/Pachyderm/Model/Filter.swift b/Pachyderm/Sources/Pachyderm/Model/FilterV1.swift similarity index 58% rename from Pachyderm/Sources/Pachyderm/Model/Filter.swift rename to Pachyderm/Sources/Pachyderm/Model/FilterV1.swift index 41b609ea..125fe61c 100644 --- a/Pachyderm/Sources/Pachyderm/Model/Filter.swift +++ b/Pachyderm/Sources/Pachyderm/Model/FilterV1.swift @@ -1,5 +1,5 @@ // -// Filter.swift +// FilterV1.swift // Pachyderm // // Created by Shadowfacts on 9/9/18. @@ -8,7 +8,7 @@ import Foundation -public struct Filter: FilterProtocol, Decodable { +public struct FilterV1: Decodable { public let id: String public let phrase: String private let context: [String] @@ -22,13 +22,12 @@ public struct Filter: FilterProtocol, Decodable { } } - public static func update(_ filter: some FilterProtocol, phrase: String? = nil, context: [Context]? = nil, irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresIn: TimeInterval? = nil) -> Request { - return Request(method: .put, path: "/api/v1/filters/\(filter.id)", body: ParametersBody([ - "phrase" => (phrase ?? filter.phrase), - "irreversible" => (irreversible ?? filter.irreversible), - "whole_word" => (wholeWord ?? filter.wholeWord), - "expires_in" => (expiresIn ?? filter.expiresAt?.timeIntervalSinceNow), - ] + "context" => (context ?? filter.contexts).contextStrings)) + public static func update(_ filterID: String, phrase: String, context: [Context], irreversible: Bool, wholeWord: Bool, expiresIn: TimeInterval?) -> Request { + return Request(method: .put, path: "/api/v1/filters/\(filterID)", body: ParametersBody([ + "phrase" => phrase, + "whole_word" => wholeWord, + "expires_in" => expiresIn, + ] + "context" => context.contextStrings)) } public static func delete(_ filterID: String) -> Request { @@ -45,7 +44,7 @@ public struct Filter: FilterProtocol, Decodable { } } -extension Filter { +extension FilterV1 { public enum Context: String, Decodable, CaseIterable { case home case notifications @@ -55,7 +54,7 @@ extension Filter { } } -extension Array where Element == Filter.Context { +extension Array where Element == FilterV1.Context { var contextStrings: [String] { return map { $0.rawValue } } diff --git a/Pachyderm/Sources/Pachyderm/Model/FilterV2.swift b/Pachyderm/Sources/Pachyderm/Model/FilterV2.swift new file mode 100644 index 00000000..5df1ceee --- /dev/null +++ b/Pachyderm/Sources/Pachyderm/Model/FilterV2.swift @@ -0,0 +1,91 @@ +// +// FilterV2.swift +// Pachyderm +// +// Created by Shadowfacts on 12/2/22. +// + +import Foundation + +public struct FilterV2: Decodable { + public let id: String + public let title: String + public let context: [FilterV1.Context] + public let expiresAt: Date? + public let action: Action + public let keywords: [Keyword] + + public static func update( + _ filterID: String, + title: String, + context: [FilterV1.Context], + expiresAt: Date?, + action: Action, + keywords keywordUpdates: [KeywordUpdate] + ) -> Request { + var keywordsParams = [Parameter]() + for (index, update) in keywordUpdates.enumerated() { + switch update { + case .update(id: let id, keyword: let keyword, wholeWord: let wholeWord): + keywordsParams.append("keywords_attributes[\(index)][id]" => id) + keywordsParams.append("keywords_attributes[\(index)][keyword]" => keyword) + keywordsParams.append("keywords_attributes[\(index)][whole_word]" => wholeWord) + case .add(keyword: let keyword, wholeWord: let wholeWord): + keywordsParams.append("keywords_attributes[\(index)][keyword]" => keyword) + keywordsParams.append("keywords_attributes[\(index)][whole_word]" => wholeWord) + case .destroy(id: let id): + keywordsParams.append("keywords_attributes[\(index)][id]" => id) + keywordsParams.append("keywords_attributes[\(index)][_destroy]" => true) + } + } + return Request(method: .put, path: "/api/v2/filters/\(filterID)", body: ParametersBody([ + "title" => title, + "expires_at" => expiresAt, + "action" => action.rawValue, + ] + "context" => context.contextStrings + keywordsParams)) + } + + private enum CodingKeys: String, CodingKey { + case id + case title + case context + case expiresAt = "expires_at" + case action = "filter_action" + case keywords + } +} + +extension FilterV2 { + public enum Action: String, Decodable, Hashable, CaseIterable { + case warn + case hide + } +} + +extension FilterV2 { + public struct Keyword: Decodable { + public let id: String + public let keyword: String + public let wholeWord: Bool + + public init(id: String, keyword: String, wholeWord: Bool) { + self.id = id + self.keyword = keyword + self.wholeWord = wholeWord + } + + private enum CodingKeys: String, CodingKey { + case id + case keyword + case wholeWord = "whole_word" + } + } +} + +extension FilterV2 { + public enum KeywordUpdate { + case update(id: String, keyword: String, wholeWord: Bool) + case add(keyword: String, wholeWord: Bool) + case destroy(id: String) + } +} diff --git a/Pachyderm/Sources/Pachyderm/Model/Protocols/FilterProtocol.swift b/Pachyderm/Sources/Pachyderm/Model/Protocols/FilterProtocol.swift deleted file mode 100644 index 5bf091b5..00000000 --- a/Pachyderm/Sources/Pachyderm/Model/Protocols/FilterProtocol.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// FilterProtocol.swift -// Pachyderm -// -// Created by Shadowfacts on 12/2/22. -// - -import Foundation - -public protocol FilterProtocol { - var id: String { get } - var phrase: String { get } - var contexts: [Filter.Context] { get } - var expiresAt: Date? { get } - var irreversible: Bool { get } - var wholeWord: Bool { get } -} diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 27d85b92..fcb02e53 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -58,7 +58,9 @@ D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */; }; D61F75A129396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */; }; D61F75A5293ABD6F00C0B37F /* EditFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75A4293ABD6F00C0B37F /* EditFilterView.swift */; }; - D61F75AD293AF39000C0B37F /* FilterContext+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75AC293AF39000C0B37F /* FilterContext+Helpers.swift */; }; + D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */; }; + D61F75AD293AF39000C0B37F /* Filter+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75AC293AF39000C0B37F /* Filter+Helpers.swift */; }; + D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75AE293AF50C00C0B37F /* EditedFilter.swift */; }; D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; }; D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; }; D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; }; @@ -433,7 +435,9 @@ D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemiCaseSensitiveComparator.swift; sourceTree = ""; }; D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemiCaseSensitiveComparatorTests.swift; sourceTree = ""; }; D61F75A4293ABD6F00C0B37F /* EditFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditFilterView.swift; sourceTree = ""; }; - D61F75AC293AF39000C0B37F /* FilterContext+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FilterContext+Helpers.swift"; sourceTree = ""; }; + D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterKeywordMO.swift; sourceTree = ""; }; + D61F75AC293AF39000C0B37F /* Filter+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Filter+Helpers.swift"; sourceTree = ""; }; + D61F75AE293AF50C00C0B37F /* EditedFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditedFilter.swift; sourceTree = ""; }; D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = ""; }; D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = ""; }; D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = ""; }; @@ -789,6 +793,7 @@ D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */, D677284D24ECC01D00C732D3 /* Draft.swift */, D627FF75217E923E00CC0648 /* DraftsManager.swift */, + D61F75AE293AF50C00C0B37F /* EditedFilter.swift */, ); path = Models; sourceTree = ""; @@ -911,6 +916,7 @@ D6B9366C2828444F00237D0E /* SavedInstance.swift */, D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */, D61F759A29384F9C00C0B37F /* FilterMO.swift */, + D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */, D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */, D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */, ); @@ -1202,7 +1208,7 @@ D6ADB6ED28EA74E8009924AB /* UIView+Configure.swift */, D63CC70F2911F1E4000E19DE /* UIScrollView+Top.swift */, D61F758F29353B4300C0B37F /* FileManager+Size.swift */, - D61F75AC293AF39000C0B37F /* FilterContext+Helpers.swift */, + D61F75AC293AF39000C0B37F /* Filter+Helpers.swift */, ); path = Extensions; sourceTree = ""; @@ -1925,7 +1931,7 @@ D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */, 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */, D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */, - D61F75AD293AF39000C0B37F /* FilterContext+Helpers.swift in Sources */, + D61F75AD293AF39000C0B37F /* Filter+Helpers.swift in Sources */, D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */, D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */, D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */, @@ -1999,6 +2005,7 @@ D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */, D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */, D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */, + D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */, D663626221360B1900C9CBA2 /* Preferences.swift in Sources */, D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */, D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */, @@ -2059,6 +2066,7 @@ D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */, D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */, D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */, + D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */, D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */, D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */, D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */, diff --git a/Tusker/API/InstanceFeatures.swift b/Tusker/API/InstanceFeatures.swift index 935dc902..902093b0 100644 --- a/Tusker/API/InstanceFeatures.swift +++ b/Tusker/API/InstanceFeatures.swift @@ -51,11 +51,11 @@ struct InstanceFeatures { } var trendingStatusesAndLinks: Bool { - instanceType.isMastodon && hasVersion(3, 5, 0) + instanceType.isMastodon && hasMastodonVersion(3, 5, 0) } var reblogVisibility: Bool { - (instanceType.isMastodon && hasVersion(2, 8, 0)) + (instanceType.isMastodon && hasMastodonVersion(2, 8, 0)) || (instanceType.isPleroma && hasPleromaVersion(2, 0, 0)) } @@ -85,11 +85,11 @@ struct InstanceFeatures { } var canFollowHashtags: Bool { - if case .mastodon(_, .some(let version)) = instanceType { - return version >= Version(4, 0, 0) - } else { - return false - } + hasMastodonVersion(4, 0, 0) + } + + var filtersV2: Bool { + hasMastodonVersion(4, 0, 0) } mutating func update(instance: Instance, nodeInfo: NodeInfo?) { @@ -138,7 +138,7 @@ struct InstanceFeatures { maxStatusChars = instance.maxStatusCharacters ?? 500 } - func hasVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool { + func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool { if case .mastodon(_, .some(let version)) = instanceType { return version >= Version(major, minor, patch) } else { diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index 02966c72..be4d22aa 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -356,9 +356,20 @@ class MastodonController: ObservableObject { func loadFilters() async { filters = (try? persistentContainer.viewContext.fetch(FilterMO.fetchRequest())) ?? [] - let req = Client.getFilters() - if let (filters, _) = try? await run(req) { - self.persistentContainer.updateFilters(filters) { + var apiFilters: [AnyFilter]? + if instanceFeatures.filtersV2 { + let req = Client.getFiltersV2() + if let (filters, _) = try? await run(req) { + apiFilters = filters.map { .v2($0) } + } + } else { + let req = Client.getFiltersV1() + if let (filters, _) = try? await run(req) { + apiFilters = filters.map { .v1($0) } + } + } + if let apiFilters { + self.persistentContainer.updateFilters(apiFilters) { if case .success(let filters) = $0 { self.filters = filters } diff --git a/Tusker/CoreData/FilterKeywordMO.swift b/Tusker/CoreData/FilterKeywordMO.swift new file mode 100644 index 00000000..80971711 --- /dev/null +++ b/Tusker/CoreData/FilterKeywordMO.swift @@ -0,0 +1,38 @@ +// +// FilterKeywordMO.swift +// Tusker +// +// Created by Shadowfacts on 12/2/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import Foundation +import CoreData +import Pachyderm + +@objc(FilterKeywordMO) +public final class FilterKeywordMO: NSManagedObject { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "FilterKeyword") + } + + @NSManaged public var id: String? + @NSManaged public var keyword: String + @NSManaged public var wholeWord: Bool + @NSManaged public var filter: FilterMO + +} + +extension FilterKeywordMO { + convenience init(apiKeyword keyword: FilterV2.Keyword, context: NSManagedObjectContext) { + self.init(context: context) + self.updateFrom(apiKeyword: keyword) + } + + func updateFrom(apiKeyword keyword: FilterV2.Keyword) { + self.id = keyword.id + self.keyword = keyword.keyword + self.wholeWord = keyword.wholeWord + } +} diff --git a/Tusker/CoreData/FilterMO.swift b/Tusker/CoreData/FilterMO.swift index ac4605d1..fa16eacc 100644 --- a/Tusker/CoreData/FilterMO.swift +++ b/Tusker/CoreData/FilterMO.swift @@ -11,21 +11,20 @@ import CoreData import Pachyderm @objc(FilterMO) -public final class FilterMO: NSManagedObject, FilterProtocol { - +public final class FilterMO: NSManagedObject { @nonobjc public class func fetchRequest() -> NSFetchRequest { return NSFetchRequest(entityName: "Filter") } @NSManaged public var id: String - @NSManaged public var phrase: String + @NSManaged public var title: String? @NSManaged private var context: String @NSManaged public var expiresAt: Date? - @NSManaged public var irreversible: Bool - @NSManaged public var wholeWord: Bool + @NSManaged public var keywords: NSMutableSet + @NSManaged public var action: String - private var _contexts: [Filter.Context]? - public var contexts: [Filter.Context] { + private var _contexts: [FilterV1.Context]? + public var contexts: [FilterV1.Context] { get { if let _contexts { return _contexts @@ -40,6 +39,23 @@ public final class FilterMO: NSManagedObject, FilterProtocol { } } + var keywordMOs: [FilterKeywordMO] { + keywords.allObjects as! [FilterKeywordMO] + } + + var titleOrKeyword: String { + title ?? (keywords.allObjects.first! as! FilterKeywordMO).keyword + } + + var filterAction: FilterV2.Action { + get { + FilterV2.Action(rawValue: action)! + } + set { + action = newValue.rawValue + } + } + public override func didChangeValue(forKey key: String) { super.didChangeValue(forKey: key) if key == "context" { @@ -50,17 +66,69 @@ public final class FilterMO: NSManagedObject, FilterProtocol { } extension FilterMO { - convenience init(apiFilter filter: Filter, context: NSManagedObjectContext) { - self.init(context: context) - self.updateFrom(apiFilter: filter) + func updateFrom(apiFilter filter: AnyFilter, context: NSManagedObjectContext) { + switch filter { + case .v1(let v1): + self.updateFrom(apiFilter: v1, context: context) + case .v2(let v2): + self.updateFrom(apiFilter: v2, context: context) + } } - func updateFrom(apiFilter filter: Filter) { + func updateFrom(apiFilter filter: FilterV1, context: NSManagedObjectContext) { self.id = filter.id - self.phrase = filter.phrase + self.title = nil self.contexts = filter.contexts self.expiresAt = filter.expiresAt - self.irreversible = filter.irreversible - self.wholeWord = filter.wholeWord + self.filterAction = .warn + let keyword: FilterKeywordMO + if self.keywords.count == 0 { + keyword = FilterKeywordMO(context: context) + keyword.filter = self + self.keywords.add(keyword) + } else { + keyword = self.keywords.allObjects.first! as! FilterKeywordMO + } + keyword.keyword = filter.phrase + keyword.wholeWord = filter.wholeWord + keyword.id = nil + } + + func updateFrom(apiFilter filter: FilterV2, context: NSManagedObjectContext) { + self.id = filter.id + self.title = filter.title + self.contexts = filter.context + self.expiresAt = filter.expiresAt + self.filterAction = filter.action + + var existing = keywordMOs + for keyword in filter.keywords { + if let mo = existing.first(where: { $0.id == keyword.id }) { + mo.updateFrom(apiKeyword: keyword) + existing.removeAll(where: { $0.id == keyword.id }) + } else { + let mo = FilterKeywordMO(context: context) + mo.updateFrom(apiKeyword: keyword) + mo.filter = self + self.keywords.add(mo) + } + } + for unupdated in existing { + context.delete(unupdated) + } + } +} + +enum AnyFilter { + case v1(FilterV1) + case v2(FilterV2) + + var id: String { + switch self { + case .v1(let v1): + return v1.id + case .v2(let v2): + return v2.id + } } } diff --git a/Tusker/CoreData/MastodonCachePersistentStore.swift b/Tusker/CoreData/MastodonCachePersistentStore.swift index f51446e4..542de629 100644 --- a/Tusker/CoreData/MastodonCachePersistentStore.swift +++ b/Tusker/CoreData/MastodonCachePersistentStore.swift @@ -299,7 +299,7 @@ class MastodonCachePersistentStore: NSPersistentContainer { } } - func updateFilters(_ filters: [Filter], completion: @escaping (Result<[FilterMO], Error>) -> Void) { + func updateFilters(_ filters: [AnyFilter], completion: @escaping (Result<[FilterMO], Error>) -> Void) { viewContext.perform { do { var all = try self.viewContext.fetch(FilterMO.fetchRequest()) @@ -309,9 +309,14 @@ class MastodonCachePersistentStore: NSPersistentContainer { try self.viewContext.execute(NSBatchDeleteRequest(objectIDs: toDelete)) } - for filter in filters where !all.contains(where: { $0.id == filter.id }) { - let mo = FilterMO(apiFilter: filter, context: self.viewContext) - all.append(mo) + for filter in filters { + if let existing = all.first(where: { $0.id == filter.id }) { + existing.updateFrom(apiFilter: filter, context: self.viewContext) + } else { + let mo = FilterMO(context: self.viewContext) + mo.updateFrom(apiFilter: filter, context: self.viewContext) + all.append(mo) + } } self.save(context: self.viewContext) diff --git a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents index 82324f01..50afc182 100644 --- a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents +++ b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents @@ -29,12 +29,18 @@ + - - + + + + + + + diff --git a/Tusker/Extensions/FilterContext+Helpers.swift b/Tusker/Extensions/Filter+Helpers.swift similarity index 66% rename from Tusker/Extensions/FilterContext+Helpers.swift rename to Tusker/Extensions/Filter+Helpers.swift index c8ead619..f7c49cfb 100644 --- a/Tusker/Extensions/FilterContext+Helpers.swift +++ b/Tusker/Extensions/Filter+Helpers.swift @@ -1,5 +1,5 @@ // -// FilterContext+Helpers.swift +// Filter+Helpers.swift // Tusker // // Created by Shadowfacts on 12/2/22. @@ -8,7 +8,7 @@ import Pachyderm -extension Filter.Context { +extension FilterV1.Context { var displayName: String { switch self { case .home: @@ -24,3 +24,14 @@ extension Filter.Context { } } } + +extension FilterV2.Action { + var displayName: String { + switch self { + case .warn: + return "Warn" + case .hide: + return "Hide" + } + } +} diff --git a/Tusker/Models/EditedFilter.swift b/Tusker/Models/EditedFilter.swift new file mode 100644 index 00000000..2fe90559 --- /dev/null +++ b/Tusker/Models/EditedFilter.swift @@ -0,0 +1,38 @@ +// +// EditedFilter.swift +// Tusker +// +// Created by Shadowfacts on 12/2/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import Foundation +import Pachyderm + +class EditedFilter: ObservableObject { + let id: String? + @Published var title: String? + @Published var contexts: [FilterV1.Context] + @Published var expiresIn: TimeInterval? + @Published var keywords: [Keyword] + @Published var action: FilterV2.Action + + init(_ mo: FilterMO) { + self.id = mo.id + self.title = mo.title + self.contexts = mo.contexts + if let expiresAt = mo.expiresAt { + expiresIn = expiresAt.timeIntervalSinceNow + } + self.keywords = mo.keywordMOs.map { + Keyword(id: $0.id, keyword: $0.keyword, wholeWord: $0.wholeWord) + } + self.action = mo.filterAction + } + + struct Keyword { + let id: String? + var keyword: String + var wholeWord: Bool + } +} diff --git a/Tusker/Screens/Filters/EditFilterView.swift b/Tusker/Screens/Filters/EditFilterView.swift index 49c5fb8e..d6433f13 100644 --- a/Tusker/Screens/Filters/EditFilterView.swift +++ b/Tusker/Screens/Filters/EditFilterView.swift @@ -27,19 +27,18 @@ struct EditFilterView: View { return durations.map { .init(value: $0, title: f.string(from: $0)!) } }() - @ObservedObject var filter: FilterMO - let updateFilter: () -> Void + @ObservedObject var filter: EditedFilter + let updateFilter: (EditedFilter) -> Void @EnvironmentObject private var mastodonController: MastodonController @State private var edited = false - init(filter: FilterMO, updateFilter: @escaping () -> Void) { + init(filter: EditedFilter, updateFilter: @escaping (EditedFilter) -> Void) { self.filter = filter self.updateFilter = updateFilter - if let expiresAt = filter.expiresAt { - let dist = expiresAt.timeIntervalSinceNow + if let expiresIn = filter.expiresIn { self.expiresIn = Self.expiresInOptions.min(by: { a, b in - let aDist = abs(a.value - dist) - let bDist = abs(b.value - dist) + let aDist = abs(a.value - expiresIn) + let bDist = abs(b.value - expiresIn) return aDist < bDist })!.value } else { @@ -49,32 +48,68 @@ struct EditFilterView: View { @State private var expiresIn: TimeInterval { didSet { - if filter.expiresAt != nil { - filter.expiresAt = Date(timeIntervalSinceNow: expiresIn) + if expires.wrappedValue { + filter.expiresIn = expiresIn } } } private var expires: Binding { Binding { - filter.expiresAt != nil + filter.expiresIn != nil } set: { newValue in - if newValue { - filter.expiresAt = Date(timeIntervalSinceNow: expiresIn) - } else { - filter.expiresAt = nil - } + filter.expiresIn = newValue ? expiresIn : nil } } var body: some View { Form { - Section { - TextField("Phrase", text: $filter.phrase) - Toggle("Whole Word", isOn: $filter.wholeWord) + if mastodonController.instanceFeatures.filtersV2 { + Section { + TextField("Title", text: Binding(get: { + filter.title ?? "" + }, set: { newValue in + filter.title = newValue + })) + } } Section { + ForEach(Array($filter.keywords.enumerated()), id: \.offset) { keyword in + VStack { + TextField("Phrase", text: keyword.element.keyword) + Toggle("Whole Word", isOn: keyword.element.wholeWord) + } + } + .onDelete(perform: mastodonController.instanceFeatures.filtersV2 ? { indices in + filter.keywords.remove(atOffsets: indices) + } : nil) + + if mastodonController.instanceFeatures.filtersV2 { + Button { + let new = EditedFilter.Keyword(id: nil, keyword: "", wholeWord: true) + withAnimation { + filter.keywords.append(new) + } + } label: { + Label("Add Keyword", systemImage: "plus") + } + } + } + + Section { + if mastodonController.instanceFeatures.filtersV2 { + Picker(selection: $filter.action) { + ForEach(FilterV2.Action.allCases, id: \.self) { action in + Text(action.displayName).tag(action) + } + } label: { + Text("Action") + } + } + + Toggle("Expires", isOn: expires) + if expires.wrappedValue { Picker(selection: $expiresIn) { ForEach(Self.expiresInOptions, id: \.value) { option in @@ -87,7 +122,7 @@ struct EditFilterView: View { } Section { - ForEach(Filter.Context.allCases, id: \.rawValue) { context in + ForEach(FilterV1.Context.allCases, id: \.rawValue) { context in Toggle(isOn: Binding(get: { filter.contexts.contains(context) }, set: { newValue in @@ -114,7 +149,7 @@ struct EditFilterView: View { }) .onDisappear { if edited { - updateFilter() + updateFilter(filter) } } } diff --git a/Tusker/Screens/Filters/FilterRow.swift b/Tusker/Screens/Filters/FilterRow.swift index cb16f5f8..c8239f4d 100644 --- a/Tusker/Screens/Filters/FilterRow.swift +++ b/Tusker/Screens/Filters/FilterRow.swift @@ -11,11 +11,12 @@ import Pachyderm struct FilterRow: View { @ObservedObject var filter: FilterMO + @EnvironmentObject var mastodonController: MastodonController var body: some View { VStack(alignment: .leading) { HStack(alignment: .top) { - Text(filter.phrase) + Text(mastodonController.instanceFeatures.filtersV2 ? filter.title ?? "" : filter.keywordMOs.first!.keyword) .font(.headline) Spacer() @@ -33,10 +34,10 @@ struct FilterRow: View { } // rather than mapping over filter.contexts, because we want a consistent order - Text(Filter.Context.allCases.filter { filter.contexts.contains($0) }.map(\.displayName).formatted()) + Text(FilterV1.Context.allCases.filter { filter.contexts.contains($0) }.map(\.displayName).formatted()) .font(.subheadline) - if filter.wholeWord { + if !mastodonController.instanceFeatures.filtersV2 && filter.keywordMOs.first!.wholeWord { Text("Whole word") .font(.subheadline) .foregroundColor(.secondary) @@ -49,10 +50,9 @@ struct FilterRow_Previews: PreviewProvider { static var previews: some View { let filter = FilterMO() filter.id = "1" - filter.phrase = "test" +// filter.phrase = "test" filter.expiresAt = Date().addingTimeInterval(60 * 60) - filter.wholeWord = true - filter.irreversible = false +// filter.wholeWord = true filter.contexts = [.home] return FilterRow(filter: filter) } diff --git a/Tusker/Screens/Filters/FiltersView.swift b/Tusker/Screens/Filters/FiltersView.swift index 648e8b50..5dbfd06b 100644 --- a/Tusker/Screens/Filters/FiltersView.swift +++ b/Tusker/Screens/Filters/FiltersView.swift @@ -13,14 +13,15 @@ struct FiltersView: View { let mastodonController: MastodonController var body: some View { - FiltersList(mastodonController: mastodonController) + FiltersList() + .environmentObject(mastodonController) .environment(\.managedObjectContext, mastodonController.persistentContainer.viewContext) } } struct FiltersList: View { - let mastodonController: MastodonController + @EnvironmentObject private var mastodonController: MastodonController @FetchRequest(sortDescriptors: []) private var filters: FetchedResults @Environment(\.dismiss) private var dismiss @State private var deletionError: (any Error)? @@ -40,11 +41,11 @@ struct FiltersList: View { } private var unexpiredFilters: [FilterMO] { - filters.filter { $0.expiresAt == nil || $0.expiresAt! > Date() }.sorted(using: SemiCaseSensitiveComparator.keyPath(\.phrase)) + filters.filter { $0.expiresAt == nil || $0.expiresAt! > Date() }.sorted(using: SemiCaseSensitiveComparator.keyPath(\.titleOrKeyword)) } private var expiredFilters: [FilterMO] { - filters.filter { $0.expiresAt != nil && $0.expiresAt! <= Date() }.sorted(using: SemiCaseSensitiveComparator.keyPath(\.phrase)) + filters.filter { $0.expiresAt != nil && $0.expiresAt! <= Date() }.sorted(using: SemiCaseSensitiveComparator.keyPath(\.titleOrKeyword)) } private var navigationBody: some View { @@ -84,9 +85,7 @@ struct FiltersList: View { Section { ForEach(filters, id: \.id) { filter in NavigationLink { - EditFilterView(filter: filter) { - updateFilter(filter) - } + EditFilterView(filter: EditedFilter(filter), updateFilter: updateFilter) } label: { FilterRow(filter: filter) } @@ -109,7 +108,7 @@ struct FiltersList: View { private func deleteFilter(_ filter: FilterMO) { Task { @MainActor in - let req = Filter.delete(filter.id) + let req = FilterV1.delete(filter.id) do { _ = try await mastodonController.run(req) let context = mastodonController.persistentContainer.viewContext @@ -121,13 +120,41 @@ struct FiltersList: View { } } - private func updateFilter(_ filter: FilterMO) { + private func updateFilter(_ filter: EditedFilter) { Task { @MainActor in - let req = Filter.update(filter) do { - let (updated, _) = try await mastodonController.run(req) - filter.updateFrom(apiFilter: updated) - mastodonController.persistentContainer.save(context: mastodonController.persistentContainer.viewContext) + let mo = filters.first(where: { $0.id == filter.id })! + + let updateFrom: AnyFilter + if mastodonController.instanceFeatures.filtersV2 { + var expiresAt: Date? + if let expiresIn = filter.expiresIn { + expiresAt = Date(timeIntervalSinceNow: expiresIn) + } + var updates = filter.keywords.map { + if let id = $0.id { + return FilterV2.KeywordUpdate.update(id: id, keyword: $0.keyword, wholeWord: $0.wholeWord) + } else { + return FilterV2.KeywordUpdate.add(keyword: $0.keyword, wholeWord: $0.wholeWord) + } + } + for existing in mo.keywordMOs where !filter.keywords.contains(where: { existing.id == $0.id }) { + if let id = existing.id { + updates.append(.destroy(id: id)) + } + } + let req = FilterV2.update(filter.id!, title: filter.title ?? "", context: filter.contexts, expiresAt: expiresAt, action: filter.action, keywords: updates) + let (updated, _) = try await mastodonController.run(req) + updateFrom = .v2(updated) + } else { + let req = FilterV1.update(filter.id!, phrase: filter.keywords.first!.keyword, context: filter.contexts, irreversible: false, wholeWord: filter.keywords.first!.wholeWord, expiresIn: filter.expiresIn) + let (updated, _) = try await mastodonController.run(req) + updateFrom = .v1(updated) + } + + let context = mastodonController.persistentContainer.viewContext + mo.updateFrom(apiFilter: updateFrom, context: context) + mastodonController.persistentContainer.save(context: context) } catch { self.updatingError = error } diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index a9a86dcf..ae1aeb88 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -96,6 +96,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro } NotificationCenter.default.addObserver(self, selector: #selector(sceneWillEnterForeground), name: UIScene.willEnterForegroundNotification, object: nil) + + mastodonController.run(Client.getFiltersV2()) { response in + print(response) + } } // separate method because InstanceTimelineViewController needs to be able to customize it