mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-25 09:41:00 +00:00
Filters WIP
This commit is contained in:
parent
ef0b0efd17
commit
b073f31e77
16 changed files with 570 additions and 12 deletions
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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
56
Model/Filter.swift
Normal 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: "")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
94
Networking/Mastodon API/Endpoints/FilterEndpoint.swift
Normal file
94
Networking/Mastodon API/Endpoints/FilterEndpoint.swift
Normal 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
|
||||
}()
|
||||
}
|
29
Networking/Mastodon API/Endpoints/FiltersEndpoint.swift
Normal file
29
Networking/Mastodon API/Endpoints/FiltersEndpoint.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
|
|
57
View Models/EditFilterViewModel.swift
Normal file
57
View Models/EditFilterViewModel.swift
Normal 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)
|
||||
}
|
||||
}
|
33
View Models/FiltersViewModel.swift
Normal file
33
View Models/FiltersViewModel.swift
Normal 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)
|
||||
}
|
||||
}
|
|
@ -24,4 +24,8 @@ extension PreferencesViewModel {
|
|||
func notificationTypesPreferencesViewModel() -> NotificationTypesPreferencesViewModel {
|
||||
NotificationTypesPreferencesViewModel(identityService: identityService)
|
||||
}
|
||||
|
||||
func filtersViewModel() -> FiltersViewModel {
|
||||
FiltersViewModel(identityService: identityService)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
90
Views/EditFilterView.swift
Normal file
90
Views/EditFilterView.swift
Normal 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
42
Views/FiltersView.swift
Normal 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
|
|
@ -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(
|
||||
|
|
Loading…
Reference in a new issue