Filters WIP

This commit is contained in:
Justin Mazzocchi 2020-08-29 03:26:26 -07:00
parent ef0b0efd17
commit b073f31e77
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
16 changed files with 570 additions and 12 deletions

View file

@ -45,7 +45,7 @@ extension ContentDatabase {
.eraseToAnyPublisher()
}
func updateLists(_ lists: [MastodonList]) -> AnyPublisher<Never, Error> {
func setLists(_ lists: [MastodonList]) -> AnyPublisher<Never, Error> {
databaseQueue.writePublisher {
for list in lists {
try Timeline.list(list).save($0)
@ -65,10 +65,34 @@ extension ContentDatabase {
func deleteList(id: String) -> AnyPublisher<Never, Error> {
databaseQueue.writePublisher(updates: Timeline.filter(Column("id") == id).deleteAll)
.ignoreOutput()
.eraseToAnyPublisher()
}
func setFilters(_ filters: [Filter]) -> AnyPublisher<Never, Error> {
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<Never, Error> {
databaseQueue.writePublisher(updates: filter.save)
.ignoreOutput()
.eraseToAnyPublisher()
}
func deleteFilter(id: String) -> AnyPublisher<Never, Error> {
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

View file

@ -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))

View file

@ -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";

View file

@ -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 = "<group>"; };
D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListsView.swift; sourceTree = "<group>"; };
D0BEB20024FA0220001B0F04 /* ListEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListEndpoint.swift; sourceTree = "<group>"; };
D0BEB20424FA1107001B0F04 /* FiltersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersView.swift; sourceTree = "<group>"; };
D0BEB20624FA1121001B0F04 /* FiltersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersViewModel.swift; sourceTree = "<group>"; };
D0BEB20824FA1136001B0F04 /* Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Filter.swift; sourceTree = "<group>"; };
D0BEB20A24FA12D8001B0F04 /* FiltersEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersEndpoint.swift; sourceTree = "<group>"; };
D0BEB20C24FA193A001B0F04 /* FilterEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterEndpoint.swift; sourceTree = "<group>"; };
D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditFilterView.swift; sourceTree = "<group>"; };
D0BEB21224FA2C0A001B0F04 /* EditFilterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditFilterViewModel.swift; sourceTree = "<group>"; };
D0C7D41E24F76169001EBDBB /* Metatext.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Metatext.entitlements; sourceTree = "<group>"; };
D0C7D41F24F76169001EBDBB /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D0C7D42224F76169001EBDBB /* IdentitiesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentitiesView.swift; sourceTree = "<group>"; };
@ -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 */,

56
Model/Filter.swift Normal file
View file

@ -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: "")
}
}
}

View file

@ -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
}
}

View file

@ -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
}()
}

View file

@ -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
}
}
}

View file

@ -88,14 +88,10 @@ extension IdentityService {
func refreshLists() -> AnyPublisher<Never, Error> {
networkClient.request(ListsEndpoint.lists)
.flatMap(contentDatabase.updateLists(_:))
.flatMap(contentDatabase.setLists(_:))
.eraseToAnyPublisher()
}
func listsObservation() -> AnyPublisher<[Timeline], Error> {
contentDatabase.listsObservation()
}
func createList(title: String) -> AnyPublisher<Never, Error> {
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<Never, Error> {
networkClient.request(FiltersEndpoint.filters)
.flatMap(contentDatabase.setFilters(_:))
.eraseToAnyPublisher()
}
func createFilter(_ filter: Filter) -> AnyPublisher<Never, Error> {
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<Never, Error> {
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<Never, Error> {
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<Never, Error> {
identityDatabase.updatePreferences(preferences, forIdentityID: identity.id)
.zip(Just(self).first().setFailureType(to: Error.self))

View file

@ -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<Date>
let saveCompleted: AnyPublisher<Void, Never>
private let identityService: IdentityService
private let saveCompletedInput = PassthroughSubject<Void, Never>()
private var cancellables = Set<AnyCancellable>()
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)
}
}

View file

@ -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<AnyCancellable>()
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)
}
}

View file

@ -24,4 +24,8 @@ extension PreferencesViewModel {
func notificationTypesPreferencesViewModel() -> NotificationTypesPreferencesViewModel {
NotificationTypesPreferencesViewModel(identityService: identityService)
}
func filtersViewModel() -> FiltersViewModel {
FiltersViewModel(identityService: identityService)
}
}

View file

@ -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)

View file

@ -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

42
Views/FiltersView.swift Normal file
View file

@ -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

View file

@ -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(