From 83ca7f132186e3c997228638acf99779622ef356 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 3 Dec 2022 14:17:10 -0500 Subject: [PATCH] Creating filters UI --- Pachyderm/Sources/Pachyderm/Client.swift | 4 +- .../Sources/Pachyderm/Model/FilterV2.swift | 28 +++++- Tusker/Models/EditedFilter.swift | 31 +++++++ Tusker/Screens/Filters/EditFilterView.swift | 51 ++++++++--- Tusker/Screens/Filters/FilterRow.swift | 4 + Tusker/Screens/Filters/FiltersView.swift | 91 +++++++++++-------- 6 files changed, 158 insertions(+), 51 deletions(-) diff --git a/Pachyderm/Sources/Pachyderm/Client.swift b/Pachyderm/Sources/Pachyderm/Client.swift index 847d0f4d..c38755e1 100644 --- a/Pachyderm/Sources/Pachyderm/Client.swift +++ b/Pachyderm/Sources/Pachyderm/Client.swift @@ -233,12 +233,12 @@ public class Client { return Request<[FilterV1]>(method: .get, path: "/api/v1/filters") } - public static func createFilterV1(phrase: String, context: [FilterV1.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request { + public static func createFilterV1(phrase: String, context: [FilterV1.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresIn: TimeInterval? = nil) -> Request { return Request(method: .post, path: "/api/v1/filters", body: ParametersBody([ "phrase" => phrase, "irreversible" => irreversible, "whole_word" => wholeWord, - "expires_at" => expiresAt + "expires_in" => expiresIn, ] + "context" => context.contextStrings)) } diff --git a/Pachyderm/Sources/Pachyderm/Model/FilterV2.swift b/Pachyderm/Sources/Pachyderm/Model/FilterV2.swift index 5df1ceee..83b83b9a 100644 --- a/Pachyderm/Sources/Pachyderm/Model/FilterV2.swift +++ b/Pachyderm/Sources/Pachyderm/Model/FilterV2.swift @@ -19,7 +19,7 @@ public struct FilterV2: Decodable { _ filterID: String, title: String, context: [FilterV1.Context], - expiresAt: Date?, + expiresIn: TimeInterval?, action: Action, keywords keywordUpdates: [KeywordUpdate] ) -> Request { @@ -40,7 +40,31 @@ public struct FilterV2: Decodable { } return Request(method: .put, path: "/api/v2/filters/\(filterID)", body: ParametersBody([ "title" => title, - "expires_at" => expiresAt, + "expires_in" => expiresIn, + "action" => action.rawValue, + ] + "context" => context.contextStrings + keywordsParams)) + } + + public static func create( + title: String, + context: [FilterV1.Context], + expiresIn: TimeInterval?, + action: Action, + keywords keywordUpdates: [KeywordUpdate] + ) -> Request { + var keywordsParams = [Parameter]() + for (index, update) in keywordUpdates.enumerated() { + switch update { + case .add(keyword: let keyword, wholeWord: let wholeWord): + keywordsParams.append("keywords_attributes[\(index)][keyword]" => keyword) + keywordsParams.append("keywords_attributes[\(index)][whole_word]" => wholeWord) + default: + fatalError("can only add keywords when creating filter") + } + } + return Request(method: .post, path: "/api/v2/filters", body: ParametersBody([ + "title" => title, + "expires_in" => expiresIn, "action" => action.rawValue, ] + "context" => context.contextStrings + keywordsParams)) } diff --git a/Tusker/Models/EditedFilter.swift b/Tusker/Models/EditedFilter.swift index 2fe90559..2501c7e9 100644 --- a/Tusker/Models/EditedFilter.swift +++ b/Tusker/Models/EditedFilter.swift @@ -17,6 +17,15 @@ class EditedFilter: ObservableObject { @Published var keywords: [Keyword] @Published var action: FilterV2.Action + init() { + self.id = nil + self.title = nil + self.contexts = [.home] + self.expiresIn = nil + self.keywords = [.init(id: nil, keyword: "", wholeWord: true)] + self.action = .warn + } + init(_ mo: FilterMO) { self.id = mo.id self.title = mo.title @@ -30,6 +39,28 @@ class EditedFilter: ObservableObject { self.action = mo.filterAction } + init(copying other: EditedFilter) { + self.id = other.id + self.title = other.title + self.contexts = other.contexts + self.expiresIn = other.expiresIn + self.keywords = other.keywords + self.action = other.action + } + + func isValid(for mastodonController: MastodonController) -> Bool { + if mastodonController.instanceFeatures.filtersV2 && (title == nil || title!.isEmpty) { + return false + } + if keywords.isEmpty || keywords.contains(where: { $0.keyword.isEmpty }) { + return false + } + if contexts.isEmpty { + return false + } + return true + } + struct Keyword { let id: String? var keyword: String diff --git a/Tusker/Screens/Filters/EditFilterView.swift b/Tusker/Screens/Filters/EditFilterView.swift index d6433f13..9e96e84a 100644 --- a/Tusker/Screens/Filters/EditFilterView.swift +++ b/Tusker/Screens/Filters/EditFilterView.swift @@ -28,21 +28,28 @@ struct EditFilterView: View { }() @ObservedObject var filter: EditedFilter - let updateFilter: (EditedFilter) -> Void + let create: Bool + let saveFilter: (EditedFilter) async throws -> Void @EnvironmentObject private var mastodonController: MastodonController + @Environment(\.dismiss) private var dismiss + @State private var originalFilter: EditedFilter @State private var edited = false + @State private var isSaving = false + @State private var saveError: (any Error)? - init(filter: EditedFilter, updateFilter: @escaping (EditedFilter) -> Void) { + init(filter: EditedFilter, create: Bool, saveFilter: @escaping (EditedFilter) async throws -> Void) { self.filter = filter - self.updateFilter = updateFilter + self.create = create + self.saveFilter = saveFilter + self._originalFilter = State(wrappedValue: EditedFilter(copying: filter)) if let expiresIn = filter.expiresIn { - self.expiresIn = Self.expiresInOptions.min(by: { a, b in + self._expiresIn = State(wrappedValue: Self.expiresInOptions.min(by: { a, b in let aDist = abs(a.value - expiresIn) let bDist = abs(b.value - expiresIn) return aDist < bDist - })!.value + })!.value) } else { - self.expiresIn = 24 * 60 * 60 + self._expiresIn = State(wrappedValue: 24 * 60 * 60) } } @@ -144,14 +151,36 @@ struct EditFilterView: View { } .navigationTitle("Edit Filter") .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + if isSaving { + ProgressView() + .progressViewStyle(.circular) + } else { + Button(create ? "Create" : "Save") { + Task { + do { + isSaving = true + try await saveFilter(filter) + dismiss() + } catch { + isSaving = false + saveError = error + } + } + } + .disabled(!filter.isValid(for: mastodonController) || !edited) + } + } + } + .alertWithData("Error Saving Filter", data: $saveError, actions: { _ in + Button("OK") {} + }, message: { error in + Text(error.localizedDescription) + }) .onReceive(filter.objectWillChange, perform: { _ in edited = true }) - .onDisappear { - if edited { - updateFilter(filter) - } - } } } diff --git a/Tusker/Screens/Filters/FilterRow.swift b/Tusker/Screens/Filters/FilterRow.swift index c8239f4d..801b7eb8 100644 --- a/Tusker/Screens/Filters/FilterRow.swift +++ b/Tusker/Screens/Filters/FilterRow.swift @@ -33,6 +33,10 @@ struct FilterRow: View { } } + if mastodonController.instanceFeatures.filtersV2 { + Text("^[\(filter.keywords.count) keywords](inflect: true)") + } + // rather than mapping over filter.contexts, because we want a consistent order Text(FilterV1.Context.allCases.filter { filter.contexts.contains($0) }.map(\.displayName).formatted()) .font(.subheadline) diff --git a/Tusker/Screens/Filters/FiltersView.swift b/Tusker/Screens/Filters/FiltersView.swift index 5dbfd06b..2f61b46c 100644 --- a/Tusker/Screens/Filters/FiltersView.swift +++ b/Tusker/Screens/Filters/FiltersView.swift @@ -50,6 +50,15 @@ struct FiltersList: View { private var navigationBody: some View { List { + Section { + NavigationLink { + EditFilterView(filter: EditedFilter(), create: true, saveFilter: createFilter) + } label: { + Label("Add Filter", systemImage: "plus") + .foregroundColor(.accentColor) + } + } + filtersSection(unexpiredFilters) filtersSection(expiredFilters) } @@ -85,7 +94,7 @@ struct FiltersList: View { Section { ForEach(filters, id: \.id) { filter in NavigationLink { - EditFilterView(filter: EditedFilter(filter), updateFilter: updateFilter) + EditFilterView(filter: EditedFilter(filter), create: false, saveFilter: updateFilter) } label: { FilterRow(filter: filter) } @@ -120,45 +129,55 @@ struct FiltersList: View { } } - private func updateFilter(_ filter: EditedFilter) { - Task { @MainActor in - do { - 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) + private func updateFilter(_ filter: EditedFilter) async throws { + let mo = filters.first(where: { $0.id == filter.id })! + + let updateFrom: AnyFilter + if mastodonController.instanceFeatures.filtersV2 { + var updates = filter.keywords.map { + if let id = $0.id { + return FilterV2.KeywordUpdate.update(id: id, keyword: $0.keyword, wholeWord: $0.wholeWord) } 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) + return FilterV2.KeywordUpdate.add(keyword: $0.keyword, wholeWord: $0.wholeWord) } - - let context = mastodonController.persistentContainer.viewContext - mo.updateFrom(apiFilter: updateFrom, context: context) - mastodonController.persistentContainer.save(context: context) - } catch { - self.updatingError = error } + 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, expiresIn: filter.expiresIn, 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) + } + + private func createFilter(_ filter: EditedFilter) async throws { + let updateFrom: AnyFilter + if mastodonController.instanceFeatures.filtersV2 { + let updates = filter.keywords.map { + FilterV2.KeywordUpdate.add(keyword: $0.keyword, wholeWord: $0.wholeWord) + } + let req = FilterV2.create(title: filter.title!, context: filter.contexts, expiresIn: filter.expiresIn, action: filter.action, keywords: updates) + let (updated, _) = try await mastodonController.run(req) + updateFrom = .v2(updated) + } else { + let req = Client.createFilterV1(phrase: filter.keywords.first!.keyword, context: filter.contexts, irreversible: nil, wholeWord: filter.keywords.first!.wholeWord, expiresIn: filter.expiresIn) + let (updated, _) = try await mastodonController.run(req) + updateFrom = .v1(updated) + } + let context = mastodonController.persistentContainer.viewContext + let mo = FilterMO(context: context) + mo.updateFrom(apiFilter: updateFrom, context: context) + mastodonController.persistentContainer.save(context: context) } }