diff --git a/Databases/ContentDatabase.swift b/Databases/ContentDatabase.swift index 043f0bf..c755672 100644 --- a/Databases/ContentDatabase.swift +++ b/Databases/ContentDatabase.swift @@ -45,7 +45,7 @@ extension ContentDatabase { .eraseToAnyPublisher() } - func updateLists(_ lists: [MastodonList]) -> AnyPublisher { + func setLists(_ lists: [MastodonList]) -> AnyPublisher { databaseQueue.writePublisher { for list in lists { try Timeline.list(list).save($0) @@ -65,10 +65,34 @@ extension ContentDatabase { func deleteList(id: String) -> AnyPublisher { databaseQueue.writePublisher(updates: Timeline.filter(Column("id") == id).deleteAll) + .ignoreOutput() + .eraseToAnyPublisher() + } + + func setFilters(_ filters: [Filter]) -> AnyPublisher { + databaseQueue.writePublisher { + for filter in filters { + try filter.save($0) + } + + try Filter.filter(!filters.map(\.id).contains(Column("id"))).deleteAll($0) + } .ignoreOutput() .eraseToAnyPublisher() } + func createFilter(_ filter: Filter) -> AnyPublisher { + databaseQueue.writePublisher(updates: filter.save) + .ignoreOutput() + .eraseToAnyPublisher() + } + + func deleteFilter(id: String) -> AnyPublisher { + databaseQueue.writePublisher(updates: Filter.filter(Column("id") == id).deleteAll) + .ignoreOutput() + .eraseToAnyPublisher() + } + func statusesObservation(timeline: Timeline) -> AnyPublisher<[Status], Error> { ValueObservation .tracking(timeline.statuses @@ -107,9 +131,16 @@ extension ContentDatabase { ValueObservation.tracking(Timeline.filter(!Timeline.nonLists.map(\.id).contains(Column("id"))) .order(Column("listTitle").collating(.localizedCaseInsensitiveCompare).asc) .fetchAll) - .removeDuplicates() - .publisher(in: databaseQueue) - .eraseToAnyPublisher() + .removeDuplicates() + .publisher(in: databaseQueue) + .eraseToAnyPublisher() + } + + func filtersObservation() -> AnyPublisher<[Filter], Error> { + ValueObservation.tracking(Filter.fetchAll) + .removeDuplicates() + .publisher(in: databaseQueue) + .eraseToAnyPublisher() } } @@ -193,6 +224,15 @@ private extension ContentDatabase { t.primaryKey(["timelineId", "statusId"], onConflict: .replace) } + + try db.create(table: "filter", ifNotExists: true) { t in + t.column("id", .text).notNull().primaryKey(onConflict: .replace) + t.column("phrase", .text).notNull() + t.column("context", .blob).notNull() + t.column("expiresAt", .date) + t.column("irreversible", .boolean).notNull() + t.column("wholeWord", .boolean).notNull() + } } try migrator.migrate(writer) @@ -297,6 +337,16 @@ private extension Timeline { } } +extension Filter: TableRecord, FetchableRecord, PersistableRecord { + static func databaseJSONDecoder(for column: String) -> JSONDecoder { + MastodonDecoder() + } + + static func databaseJSONEncoder(for column: String) -> JSONEncoder { + MastodonEncoder() + } +} + private struct TransientStatusCollectionElement: Codable, TableRecord, FetchableRecord, PersistableRecord { let transientStatusCollectionId: String let statusId: String diff --git a/Development Assets/DevelopmentModels.swift b/Development Assets/DevelopmentModels.swift index 9034ac0..98e8e6e 100644 --- a/Development Assets/DevelopmentModels.swift +++ b/Development Assets/DevelopmentModels.swift @@ -114,6 +114,14 @@ extension NotificationTypesPreferencesViewModel { static let development = NotificationTypesPreferencesViewModel(identityService: .development) } +extension FiltersViewModel { + static let development = FiltersViewModel(identityService: .development) +} + +extension EditFilterViewModel { + static let development = EditFilterViewModel(filter: .new, identityService: .development) +} + extension StatusListViewModel { static let development = StatusListViewModel( statusListService: IdentityService.development.service(timeline: .home)) diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index 4e5aefd..3b4d8dc 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -22,12 +22,30 @@ "preferences.expand-media.show-all" = "Show all"; "preferences.expand-media.hide-all" = "Hide all"; "preferences.reading-expand-spoilers" = "Always expand content warnings"; +"preferences.filters" = "Filters"; "preferences.notification-types" = "Notification Types"; "preferences.notification-types.follow" = "Follow"; "preferences.notification-types.favourite" = "Favorite"; "preferences.notification-types.reblog" = "Reblog"; "preferences.notification-types.mention" = "Mention"; "preferences.notification-types.poll" = "Poll"; +"filter.add-new" = "Add New Filter"; +"filter.edit" = "Edit Filter"; +"filter.keyword-or-phrase" = "Keyword or phrase"; +"filter.never-expires" = "Never expires"; +"filter.expire-after" = "Expire after"; +"filter.contexts" = "Filter contexts"; +"filter.irreversible" = "Drop instead of hide"; +"filter.irreversible-explanation" = "Filtered posts will disappear irreversibly, even if filter is later removed"; +"filter.whole-word" = "Whole word"; +"filter.whole-word-explanation" = "When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word"; +"filter.save-changes" = "Save Changes"; +"filter.context.home" = "Home timeline"; +"filter.context.notifications" = "Notifications"; +"filter.context.public" = "Public timelines"; +"filter.context.thread" = "Conversations"; +"filter.context.account" = "Profiles"; +"filter.context.unknown" = "Unknown context"; "status.reblogged-by" = "%@ boosted"; "status.pinned-post" = "Pinned post"; "status.show-more" = "Show More"; diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 68864e7..cfabdb2 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -33,6 +33,13 @@ D0BEB1FD24F9E4E5001B0F04 /* ListsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1FC24F9E4E5001B0F04 /* ListsViewModel.swift */; }; D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */; }; D0BEB20124FA0220001B0F04 /* ListEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB20024FA0220001B0F04 /* ListEndpoint.swift */; }; + D0BEB20524FA1107001B0F04 /* FiltersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB20424FA1107001B0F04 /* FiltersView.swift */; }; + D0BEB20724FA1121001B0F04 /* FiltersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB20624FA1121001B0F04 /* FiltersViewModel.swift */; }; + D0BEB20924FA1136001B0F04 /* Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB20824FA1136001B0F04 /* Filter.swift */; }; + D0BEB20B24FA12D8001B0F04 /* FiltersEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB20A24FA12D8001B0F04 /* FiltersEndpoint.swift */; }; + D0BEB20D24FA193A001B0F04 /* FilterEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB20C24FA193A001B0F04 /* FilterEndpoint.swift */; }; + D0BEB21124FA2A91001B0F04 /* EditFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */; }; + D0BEB21324FA2C0A001B0F04 /* EditFilterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB21224FA2C0A001B0F04 /* EditFilterViewModel.swift */; }; D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42224F76169001EBDBB /* IdentitiesView.swift */; }; D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */; }; D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42424F76169001EBDBB /* AddIdentityView.swift */; }; @@ -206,6 +213,13 @@ D0BEB1FC24F9E4E5001B0F04 /* ListsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListsViewModel.swift; sourceTree = ""; }; D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListsView.swift; sourceTree = ""; }; D0BEB20024FA0220001B0F04 /* ListEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListEndpoint.swift; sourceTree = ""; }; + D0BEB20424FA1107001B0F04 /* FiltersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersView.swift; sourceTree = ""; }; + D0BEB20624FA1121001B0F04 /* FiltersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersViewModel.swift; sourceTree = ""; }; + D0BEB20824FA1136001B0F04 /* Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Filter.swift; sourceTree = ""; }; + D0BEB20A24FA12D8001B0F04 /* FiltersEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersEndpoint.swift; sourceTree = ""; }; + D0BEB20C24FA193A001B0F04 /* FilterEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterEndpoint.swift; sourceTree = ""; }; + D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditFilterView.swift; sourceTree = ""; }; + D0BEB21224FA2C0A001B0F04 /* EditFilterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditFilterViewModel.swift; sourceTree = ""; }; D0C7D41E24F76169001EBDBB /* Metatext.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Metatext.entitlements; sourceTree = ""; }; D0C7D41F24F76169001EBDBB /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D0C7D42224F76169001EBDBB /* IdentitiesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentitiesView.swift; sourceTree = ""; }; @@ -431,6 +445,8 @@ D0C7D42424F76169001EBDBB /* AddIdentityView.swift */, D01F41E024F8885900D55A2D /* Attachments */, D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */, + D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */, + D0BEB20424FA1107001B0F04 /* FiltersView.swift */, D0C7D42224F76169001EBDBB /* IdentitiesView.swift */, D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */, D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */, @@ -474,6 +490,7 @@ D0C7D43A24F76169001EBDBB /* Attachment.swift */, D0C7D44124F76169001EBDBB /* Card.swift */, D0C7D44B24F76169001EBDBB /* Emoji.swift */, + D0BEB20824FA1136001B0F04 /* Filter.swift */, D0C7D44224F76169001EBDBB /* HTML.swift */, D0C7D43B24F76169001EBDBB /* Identity.swift */, D0C7D44524F76169001EBDBB /* Instance.swift */, @@ -517,6 +534,8 @@ children = ( D0C7D46024F76169001EBDBB /* AddIdentityViewModel.swift */, D01F41DE24F8868800D55A2D /* AttachmentViewModel.swift */, + D0BEB21224FA2C0A001B0F04 /* EditFilterViewModel.swift */, + D0BEB20624FA1121001B0F04 /* FiltersViewModel.swift */, D0C7D45F24F76169001EBDBB /* IdentitiesViewModel.swift */, D0BEB1FC24F9E4E5001B0F04 /* ListsViewModel.swift */, D0C7D45D24F76169001EBDBB /* NotificationTypesPreferencesViewModel.swift */, @@ -591,6 +610,8 @@ D0C7D47F24F76169001EBDBB /* AppAuthorizationEndpoint.swift */, D0C7D48324F76169001EBDBB /* ContextEndpoint.swift */, D0C7D48224F76169001EBDBB /* DeletionEndpoint.swift */, + D0BEB20C24FA193A001B0F04 /* FilterEndpoint.swift */, + D0BEB20A24FA12D8001B0F04 /* FiltersEndpoint.swift */, D0C7D47D24F76169001EBDBB /* InstanceEndpoint.swift */, D0BEB20024FA0220001B0F04 /* ListEndpoint.swift */, D0BEB1F824F9D627001B0F04 /* ListsEndpoint.swift */, @@ -874,14 +895,17 @@ D0C7D4D224F7616A001EBDBB /* ContentDatabase.swift in Sources */, D0C7D4F724F7616A001EBDBB /* StatusService.swift in Sources */, D04FD73924D4A7B4007D572D /* AccountEndpoint+Stubbing.swift in Sources */, + D0BEB21324FA2C0A001B0F04 /* EditFilterViewModel.swift in Sources */, D0C7D4FA24F7616A001EBDBB /* AllIdentitiesService.swift in Sources */, D0C7D4CD24F7616A001EBDBB /* AddIdentityViewModel.swift in Sources */, D03658D124EDD80900AC17EC /* ContextEndpoint+Stubbing.swift in Sources */, D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */, D0BEB1F524F9A216001B0F04 /* Paged.swift in Sources */, D0DC174A24CFF15F00A75C65 /* AppAuthorizationEndpoint+Stubbing.swift in Sources */, + D0BEB20D24FA193A001B0F04 /* FilterEndpoint.swift in Sources */, D0C7D49A24F7616A001EBDBB /* StatusListView.swift in Sources */, D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */, + D0BEB20924FA1136001B0F04 /* Filter.swift in Sources */, D0C7D4A524F7616A001EBDBB /* StatusListViewController.swift in Sources */, D0C7D4CC24F7616A001EBDBB /* IdentitiesViewModel.swift in Sources */, D0C7D4E024F7616A001EBDBB /* WebAuthSession.swift in Sources */, @@ -952,10 +976,13 @@ D01F41E424F8889700D55A2D /* AttachmentsView.swift in Sources */, D0C7D4AA24F7616A001EBDBB /* Attachment.swift in Sources */, D0C7D4AF24F7616A001EBDBB /* PushNotification.swift in Sources */, + D0BEB20724FA1121001B0F04 /* FiltersViewModel.swift in Sources */, D0C7D4C924F7616A001EBDBB /* TabNavigationViewModel.swift in Sources */, + D0BEB21124FA2A91001B0F04 /* EditFilterView.swift in Sources */, D0C7D4B624F7616A001EBDBB /* ListTimeline.swift in Sources */, D0C7D4E124F7616A001EBDBB /* MastodonDecoder.swift in Sources */, D0C7D4B024F7616A001EBDBB /* PushSubscription.swift in Sources */, + D0BEB20B24FA12D8001B0F04 /* FiltersEndpoint.swift in Sources */, D0C7D4C424F7616A001EBDBB /* AppDelegate.swift in Sources */, D074577A24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */, D0C7D4E424F7616A001EBDBB /* PreferencesEndpoint.swift in Sources */, @@ -966,6 +993,7 @@ D0C7D4F324F7616A001EBDBB /* ContextService.swift in Sources */, D0C7D4DD24F7616A001EBDBB /* CodingUserInfoKey+Extensions.swift in Sources */, D0C7D4D824F7616A001EBDBB /* Publisher+Extensions.swift in Sources */, + D0BEB20524FA1107001B0F04 /* FiltersView.swift in Sources */, D01F41D824F880C400D55A2D /* StatusTableViewCell.swift in Sources */, D04FD73C24D4A83A007D572D /* InstanceEndpoint+Stubbing.swift in Sources */, D0C7D4BE24F7616A001EBDBB /* Unknowable.swift in Sources */, diff --git a/Model/Filter.swift b/Model/Filter.swift new file mode 100644 index 0000000..dc8c0d4 --- /dev/null +++ b/Model/Filter.swift @@ -0,0 +1,56 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +struct Filter: Codable, Hashable, Identifiable { + enum Context: String, Codable, Unknowable { + case home + case notifications + case `public` + case thread + case account + case unknown + + static var unknownCase: Self { .unknown } + } + + let id: String + var phrase: String + var context: [Context] + var expiresAt: Date? + var irreversible: Bool + var wholeWord: Bool +} + +extension Filter { + static let newFilterID: String = "com.metabolist.metatext.new-filter-id" + static let new = Self(id: newFilterID, + phrase: "", + context: [], + expiresAt: nil, + irreversible: false, + wholeWord: true) +} + +extension Filter.Context: Identifiable { + var id: Self { self } +} + +extension Filter.Context { + var localized: String { + switch self { + case .home: + return NSLocalizedString("filter.context.home", comment: "") + case .notifications: + return NSLocalizedString("filter.context.notifications", comment: "") + case .public: + return NSLocalizedString("filter.context.public", comment: "") + case .thread: + return NSLocalizedString("filter.context.thread", comment: "") + case .account: + return NSLocalizedString("filter.context.account", comment: "") + case .unknown: + return NSLocalizedString("filter.context.unknown", comment: "") + } + } +} diff --git a/Networking/Mastodon API/Endpoints/DeletionEndpoint.swift b/Networking/Mastodon API/Endpoints/DeletionEndpoint.swift index c9146b6..38c669d 100644 --- a/Networking/Mastodon API/Endpoints/DeletionEndpoint.swift +++ b/Networking/Mastodon API/Endpoints/DeletionEndpoint.swift @@ -5,6 +5,7 @@ import Foundation enum DeletionEndpoint { case oauthRevoke(token: String, clientID: String, clientSecret: String) case list(id: String) + case filter(id: String) } extension DeletionEndpoint: MastodonEndpoint { @@ -16,6 +17,8 @@ extension DeletionEndpoint: MastodonEndpoint { return ["oauth"] case .list: return defaultContext + ["lists"] + case .filter: + return defaultContext + ["filters"] } } @@ -23,7 +26,7 @@ extension DeletionEndpoint: MastodonEndpoint { switch self { case .oauthRevoke: return ["revoke"] - case let .list(id): + case let .list(id), let .filter(id): return [id] } } @@ -32,7 +35,7 @@ extension DeletionEndpoint: MastodonEndpoint { switch self { case .oauthRevoke: return .post - case .list: + case .list, .filter: return .delete } } @@ -41,7 +44,7 @@ extension DeletionEndpoint: MastodonEndpoint { switch self { case let .oauthRevoke(token, clientID, clientSecret): return ["token": token, "client_id": clientID, "client_secret": clientSecret] - case .list: + case .list, .filter: return nil } } diff --git a/Networking/Mastodon API/Endpoints/FilterEndpoint.swift b/Networking/Mastodon API/Endpoints/FilterEndpoint.swift new file mode 100644 index 0000000..e90ea2f --- /dev/null +++ b/Networking/Mastodon API/Endpoints/FilterEndpoint.swift @@ -0,0 +1,94 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +enum FilterEndpoint { + case create( + phrase: String, + context: [Filter.Context], + irreversible: Bool, + wholeWord: Bool, + expiresIn: Date?) + case update( + id: String, + phrase: String, + context: [Filter.Context], + irreversible: Bool, + wholeWord: Bool, + expiresIn: Date?) +} + +extension FilterEndpoint: MastodonEndpoint { + typealias ResultType = Filter + + var context: [String] { + defaultContext + ["filters"] + } + + var pathComponentsInContext: [String] { + switch self { + case .create: + return [] + case let .update(id, _, _, _, _, _): + return [id] + } + } + + var parameters: [String: Any]? { + switch self { + case let .create(phrase, context, irreversible, wholeWord, expiresIn): + return params(phrase: phrase, + context: context, + irreversible: irreversible, + wholeWord: wholeWord, + expiresIn: expiresIn) + case let .update(id, phrase, context, irreversible, wholeWord, expiresIn): + var params = self.params(phrase: phrase, + context: context, + irreversible: irreversible, + wholeWord: wholeWord, + expiresIn: expiresIn) + + params["id"] = id + + return params + } + } + + var method: HTTPMethod { + switch self { + case .create: + return .post + case .update: + return .put + } + } +} + +private extension FilterEndpoint { + func params(phrase: String, + context: [Filter.Context], + irreversible: Bool, + wholeWord: Bool, + expiresIn: Date?) -> [String: Any] { + var params: [String: Any] = [ + "phrase": phrase, + "context": context.map(\.rawValue), + "irreversible": irreversible, + "whole_word": wholeWord] + + if let expiresIn = expiresIn { + params["expires_in"] = Self.dateFormatter.string(from: expiresIn) + } + + return params + } + + static let dateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + + dateFormatter.dateFormat = MastodonAPI.dateFormat + + return dateFormatter + }() +} diff --git a/Networking/Mastodon API/Endpoints/FiltersEndpoint.swift b/Networking/Mastodon API/Endpoints/FiltersEndpoint.swift new file mode 100644 index 0000000..d2adb1e --- /dev/null +++ b/Networking/Mastodon API/Endpoints/FiltersEndpoint.swift @@ -0,0 +1,29 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +enum FiltersEndpoint { + case filters +} + +extension FiltersEndpoint: MastodonEndpoint { + typealias ResultType = [Filter] + + var context: [String] { + defaultContext + ["filters"] + } + + var pathComponentsInContext: [String] { + switch self { + case .filters: + return [] + } + } + + var method: HTTPMethod { + switch self { + case .filters: + return .get + } + } +} diff --git a/Services/IdentityService.swift b/Services/IdentityService.swift index ee92e6a..d155538 100644 --- a/Services/IdentityService.swift +++ b/Services/IdentityService.swift @@ -88,14 +88,10 @@ extension IdentityService { func refreshLists() -> AnyPublisher { networkClient.request(ListsEndpoint.lists) - .flatMap(contentDatabase.updateLists(_:)) + .flatMap(contentDatabase.setLists(_:)) .eraseToAnyPublisher() } - func listsObservation() -> AnyPublisher<[Timeline], Error> { - contentDatabase.listsObservation() - } - func createList(title: String) -> AnyPublisher { networkClient.request(ListEndpoint.create(title: title)) .flatMap(contentDatabase.createList(_:)) @@ -109,6 +105,48 @@ extension IdentityService { .eraseToAnyPublisher() } + func listsObservation() -> AnyPublisher<[Timeline], Error> { + contentDatabase.listsObservation() + } + + func refreshFilters() -> AnyPublisher { + networkClient.request(FiltersEndpoint.filters) + .flatMap(contentDatabase.setFilters(_:)) + .eraseToAnyPublisher() + } + + func createFilter(_ filter: Filter) -> AnyPublisher { + networkClient.request(FilterEndpoint.create(phrase: filter.phrase, + context: filter.context, + irreversible: filter.irreversible, + wholeWord: filter.wholeWord, + expiresIn: filter.expiresAt)) + .flatMap(contentDatabase.createFilter(_:)) + .eraseToAnyPublisher() + } + + func updateFilter(_ filter: Filter) -> AnyPublisher { + networkClient.request(FilterEndpoint.update(id: filter.id, + phrase: filter.phrase, + context: filter.context, + irreversible: filter.irreversible, + wholeWord: filter.wholeWord, + expiresIn: filter.expiresAt)) + .flatMap(contentDatabase.createFilter(_:)) + .eraseToAnyPublisher() + } + + func deleteFilter(id: String) -> AnyPublisher { + networkClient.request(DeletionEndpoint.filter(id: id)) + .map { _ in id } + .flatMap(contentDatabase.deleteFilter(id:)) + .eraseToAnyPublisher() + } + + func filtersObservation() -> AnyPublisher<[Filter], Error> { + contentDatabase.filtersObservation() + } + func updatePreferences(_ preferences: Identity.Preferences) -> AnyPublisher { identityDatabase.updatePreferences(preferences, forIdentityID: identity.id) .zip(Just(self).first().setFailureType(to: Error.self)) diff --git a/View Models/EditFilterViewModel.swift b/View Models/EditFilterViewModel.swift new file mode 100644 index 0000000..f762971 --- /dev/null +++ b/View Models/EditFilterViewModel.swift @@ -0,0 +1,57 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Combine + +class EditFilterViewModel: ObservableObject { + @Published var filter: Filter + @Published var saving = false + @Published var alertItem: AlertItem? + var date: Date + let dateRange: ClosedRange + let saveCompleted: AnyPublisher + + private let identityService: IdentityService + private let saveCompletedInput = PassthroughSubject() + private var cancellables = Set() + + init(filter: Filter, identityService: IdentityService) { + self.filter = filter + self.identityService = identityService + date = Calendar.autoupdatingCurrent.date(byAdding: .minute, value: 30, to: Date()) ?? Date() + dateRange = date...(Calendar.autoupdatingCurrent.date(byAdding: .day, value: 7, to: date) ?? Date()) + saveCompleted = saveCompletedInput.eraseToAnyPublisher() + } +} + +extension EditFilterViewModel { + var isNew: Bool { filter.id == Filter.newFilterID } + + var isSaveDisabled: Bool { filter.phrase == "" || filter.context.isEmpty } + + func toggleSelection(context: Filter.Context) { + if filter.context.contains(context) { + filter.context.removeAll { $0 == context } + } else { + filter.context.append(context) + } + } + + func save() { + (isNew ? identityService.createFilter(filter) : identityService.updateFilter(filter)) + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .handleEvents( + receiveSubscription: { [weak self] _ in self?.saving = true }, + receiveCompletion: { [weak self] in + guard let self = self else { return } + + self.saving = false + + if case .finished = $0 { + self.saveCompletedInput.send(()) + } + }) + .sink { _ in } + .store(in: &cancellables) + } +} diff --git a/View Models/FiltersViewModel.swift b/View Models/FiltersViewModel.swift new file mode 100644 index 0000000..e5883e6 --- /dev/null +++ b/View Models/FiltersViewModel.swift @@ -0,0 +1,33 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Combine + +class FiltersViewModel: ObservableObject { + @Published var filters = [Filter]() + @Published var alertItem: AlertItem? + + private let identityService: IdentityService + private var cancellables = Set() + + init(identityService: IdentityService) { + self.identityService = identityService + + identityService.filtersObservation() + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .assign(to: &$filters) + } +} + +extension FiltersViewModel { + func refreshFilters() { + identityService.refreshFilters() + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .sink { _ in } + .store(in: &cancellables) + } + + func editFilterViewModel(filter: Filter) -> EditFilterViewModel { + EditFilterViewModel(filter: filter, identityService: identityService) + } +} diff --git a/View Models/PreferencesViewModel.swift b/View Models/PreferencesViewModel.swift index 1021e6f..2b6a11b 100644 --- a/View Models/PreferencesViewModel.swift +++ b/View Models/PreferencesViewModel.swift @@ -24,4 +24,8 @@ extension PreferencesViewModel { func notificationTypesPreferencesViewModel() -> NotificationTypesPreferencesViewModel { NotificationTypesPreferencesViewModel(identityService: identityService) } + + func filtersViewModel() -> FiltersViewModel { + FiltersViewModel(identityService: identityService) + } } diff --git a/View Models/TabNavigationViewModel.swift b/View Models/TabNavigationViewModel.swift index 9db22fe..2642851 100644 --- a/View Models/TabNavigationViewModel.swift +++ b/View Models/TabNavigationViewModel.swift @@ -75,6 +75,11 @@ extension TabNavigationViewModel { .sink { _ in } .store(in: &cancellables) + identityService.refreshFilters() + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .sink { _ in } + .store(in: &cancellables) + if identity.preferences.useServerPostingReadingPreferences { identityService.refreshServerPreferences() .assignErrorsToAlertItem(to: \.alertItem, on: self) diff --git a/Views/EditFilterView.swift b/Views/EditFilterView.swift new file mode 100644 index 0000000..3273be8 --- /dev/null +++ b/Views/EditFilterView.swift @@ -0,0 +1,90 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import SwiftUI + +struct EditFilterView: View { + @StateObject var viewModel: EditFilterViewModel + @Environment(\.presentationMode) var presentationMode + + var body: some View { + Form { + Section(header: Text("filter.keyword-or-phrase")) { + TextField("filter.keyword-or-phrase", text: $viewModel.filter.phrase) + } + + Section { + if viewModel.isNew || viewModel.filter.expiresAt == nil { + Toggle("filter.never-expires", isOn: .init( + get: { viewModel.filter.expiresAt == nil }, + set: { + if $0 { + viewModel.filter.expiresAt = nil + } else { + viewModel.filter.expiresAt = viewModel.date + } + })) + } + + if viewModel.filter.expiresAt != nil { + DatePicker(selection: $viewModel.date, in: viewModel.dateRange) { + Text("filter.expire-after") + } + } + } + + Section(header: Text("filter.contexts")) { + ForEach(Filter.Context.allCasesExceptUnknown) { context in + Toggle(context.localized, isOn: .init( + get: { viewModel.filter.context.contains(context) }, + set: { _ in viewModel.toggleSelection(context: context) })) + } + } + + Section { + Toggle(isOn: $viewModel.filter.irreversible, label: { + VStack(alignment: .leading, spacing: 8) { + Text("filter.irreversible") + Text("filter.irreversible-explanation") + .font(.footnote) + .foregroundColor(.secondary) + } + }) + } + + Section { + Toggle(isOn: $viewModel.filter.wholeWord, label: { + VStack(alignment: .leading, spacing: 8) { + Text("filter.whole-word") + Text("filter.whole-word-explanation") + .font(.footnote) + .foregroundColor(.secondary) + } + }) + } + } + .alertItem($viewModel.alertItem) + .onReceive(viewModel.saveCompleted) { presentationMode.wrappedValue.dismiss() } + .navigationTitle(viewModel.isNew ? "filter.add-new" : "filter.edit") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Group { + if viewModel.saving { + ProgressView() + } else { + Button(viewModel.isNew ? "add" : "filter.save-changes", + action: viewModel.save) + .disabled(viewModel.isSaveDisabled) + } + } + } + } + } +} + +#if DEBUG +struct EditFilterView_Previews: PreviewProvider { + static var previews: some View { + EditFilterView(viewModel: .development) + } +} +#endif diff --git a/Views/FiltersView.swift b/Views/FiltersView.swift new file mode 100644 index 0000000..aeaca95 --- /dev/null +++ b/Views/FiltersView.swift @@ -0,0 +1,42 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import SwiftUI + +struct FiltersView: View { + @StateObject var viewModel: FiltersViewModel + + var body: some View { + Form { + Section { + NavigationLink(destination: EditFilterView( + viewModel: viewModel.editFilterViewModel(filter: .new))) { + Label("add", systemImage: "plus.circle") + } + } + Section { + ForEach(viewModel.filters) { filter in + NavigationLink(destination: EditFilterView( + viewModel: viewModel.editFilterViewModel(filter: filter))) { + HStack { + Text(filter.phrase) + Spacer() + Text(ListFormatter.localizedString(byJoining: filter.context.map(\.localized))) + .foregroundColor(.secondary) + } + } + } + } + } + .navigationTitle("preferences.filters") + .alertItem($viewModel.alertItem) + .onAppear(perform: viewModel.refreshFilters) + } +} + +#if DEBUG +struct FiltersView_Previews: PreviewProvider { + static var previews: some View { + FiltersView(viewModel: .development) + } +} +#endif diff --git a/Views/PreferencesView.swift b/Views/PreferencesView.swift index 0ba2a8a..d8a1edc 100644 --- a/Views/PreferencesView.swift +++ b/Views/PreferencesView.swift @@ -11,6 +11,9 @@ struct PreferencesView: View { NavigationLink("preferences.posting-reading", destination: PostingReadingPreferencesView( viewModel: viewModel.postingReadingPreferencesViewModel())) + NavigationLink("preferences.filters", + destination: FiltersView( + viewModel: viewModel.filtersViewModel())) if viewModel.shouldShowNotificationTypePreferences { NavigationLink("preferences.notification-types", destination: NotificationTypesPreferencesView(