Compare commits
18 Commits
0247c50650
...
6501343f24
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 6501343f24 | |
Shadowfacts | fabe339215 | |
Shadowfacts | e1886509d3 | |
Shadowfacts | 8ad48784d9 | |
Shadowfacts | 75e9c9f986 | |
Shadowfacts | a17afe247c | |
Shadowfacts | 81abcfcf7b | |
Shadowfacts | 7e5d8675c2 | |
Shadowfacts | cde3109203 | |
Shadowfacts | fcf95ba8c1 | |
Shadowfacts | f71804f094 | |
Shadowfacts | 83ca7f1321 | |
Shadowfacts | 16a1e4008b | |
Shadowfacts | 518a8eba0a | |
Shadowfacts | 8d56a6450e | |
Shadowfacts | 8896bfbc59 | |
Shadowfacts | 4ca57f8c76 | |
Shadowfacts | c9fa11cc3b |
|
@ -229,21 +229,25 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Filters
|
// MARK: - Filters
|
||||||
public static func getFilters() -> Request<[Filter]> {
|
public static func getFiltersV1() -> Request<[FilterV1]> {
|
||||||
return Request<[Filter]>(method: .get, path: "/api/v1/filters")
|
return Request<[FilterV1]>(method: .get, path: "/api/v1/filters")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func createFilter(phrase: String, context: [Filter.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
|
public static func createFilterV1(phrase: String, context: [FilterV1.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresIn: TimeInterval? = nil) -> Request<FilterV1> {
|
||||||
return Request<Filter>(method: .post, path: "/api/v1/filters", body: ParametersBody([
|
return Request<FilterV1>(method: .post, path: "/api/v1/filters", body: ParametersBody([
|
||||||
"phrase" => phrase,
|
"phrase" => phrase,
|
||||||
"irreversible" => irreversible,
|
"irreversible" => irreversible,
|
||||||
"whole_word" => wholeWord,
|
"whole_word" => wholeWord,
|
||||||
"expires_at" => expiresAt
|
"expires_in" => expiresIn,
|
||||||
] + "context" => context.contextStrings))
|
] + "context" => context.contextStrings))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getFilter(id: String) -> Request<Filter> {
|
public static func getFilterV1(id: String) -> Request<FilterV1> {
|
||||||
return Request<Filter>(method: .get, path: "/api/v1/filters/\(id)")
|
return Request<FilterV1>(method: .get, path: "/api/v1/filters/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func getFiltersV2() -> Request<[FilterV2]> {
|
||||||
|
return Request(method: .get, path: "/api/v2/filters")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Follows
|
// MARK: - Follows
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// Filter.swift
|
// FilterV1.swift
|
||||||
// Pachyderm
|
// Pachyderm
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 9/9/18.
|
// Created by Shadowfacts on 9/9/18.
|
||||||
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class Filter: Decodable {
|
public struct FilterV1: Decodable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let phrase: String
|
public let phrase: String
|
||||||
private let context: [String]
|
private let context: [String]
|
||||||
|
@ -22,17 +22,16 @@ public class Filter: Decodable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func update(_ filter: Filter, phrase: String? = nil, context: [Context]? = nil, irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
|
public static func update(_ filterID: String, phrase: String, context: [Context], irreversible: Bool, wholeWord: Bool, expiresIn: TimeInterval?) -> Request<FilterV1> {
|
||||||
return Request<Filter>(method: .put, path: "/api/v1/filters/\(filter.id)", body: ParametersBody([
|
return Request<FilterV1>(method: .put, path: "/api/v1/filters/\(filterID)", body: ParametersBody([
|
||||||
"phrase" => (phrase ?? filter.phrase),
|
"phrase" => phrase,
|
||||||
"irreversible" => (irreversible ?? filter.irreversible),
|
"whole_word" => wholeWord,
|
||||||
"whole_word" => (wholeWord ?? filter.wholeWord),
|
"expires_in" => expiresIn,
|
||||||
"expires_at" => (expiresAt ?? filter.expiresAt)
|
] + "context" => context.contextStrings))
|
||||||
] + "context" => (context?.contextStrings ?? filter.context)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func delete(_ filter: Filter) -> Request<Empty> {
|
public static func delete(_ filterID: String) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .delete, path: "/api/v1/filters/\(filter.id)")
|
return Request<Empty>(method: .delete, path: "/api/v1/filters/\(filterID)")
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
@ -45,16 +44,17 @@ public class Filter: Decodable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Filter {
|
extension FilterV1 {
|
||||||
public enum Context: String, Decodable {
|
public enum Context: String, Decodable, CaseIterable {
|
||||||
case home
|
case home
|
||||||
case notifications
|
case notifications
|
||||||
case `public`
|
case `public`
|
||||||
case thread
|
case thread
|
||||||
|
case account
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Array where Element == Filter.Context {
|
extension Array where Element == FilterV1.Context {
|
||||||
var contextStrings: [String] {
|
var contextStrings: [String] {
|
||||||
return map { $0.rawValue }
|
return map { $0.rawValue }
|
||||||
}
|
}
|
|
@ -0,0 +1,115 @@
|
||||||
|
//
|
||||||
|
// FilterV2.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/2/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct FilterV2: Decodable {
|
||||||
|
public let id: String
|
||||||
|
public let title: String
|
||||||
|
public let context: [FilterV1.Context]
|
||||||
|
public let expiresAt: Date?
|
||||||
|
public let action: Action
|
||||||
|
public let keywords: [Keyword]
|
||||||
|
|
||||||
|
public static func update(
|
||||||
|
_ filterID: String,
|
||||||
|
title: String,
|
||||||
|
context: [FilterV1.Context],
|
||||||
|
expiresIn: TimeInterval?,
|
||||||
|
action: Action,
|
||||||
|
keywords keywordUpdates: [KeywordUpdate]
|
||||||
|
) -> Request<FilterV2> {
|
||||||
|
var keywordsParams = [Parameter]()
|
||||||
|
for (index, update) in keywordUpdates.enumerated() {
|
||||||
|
switch update {
|
||||||
|
case .update(id: let id, keyword: let keyword, wholeWord: let wholeWord):
|
||||||
|
keywordsParams.append("keywords_attributes[\(index)][id]" => id)
|
||||||
|
keywordsParams.append("keywords_attributes[\(index)][keyword]" => keyword)
|
||||||
|
keywordsParams.append("keywords_attributes[\(index)][whole_word]" => wholeWord)
|
||||||
|
case .add(keyword: let keyword, wholeWord: let wholeWord):
|
||||||
|
keywordsParams.append("keywords_attributes[\(index)][keyword]" => keyword)
|
||||||
|
keywordsParams.append("keywords_attributes[\(index)][whole_word]" => wholeWord)
|
||||||
|
case .destroy(id: let id):
|
||||||
|
keywordsParams.append("keywords_attributes[\(index)][id]" => id)
|
||||||
|
keywordsParams.append("keywords_attributes[\(index)][_destroy]" => true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Request(method: .put, path: "/api/v2/filters/\(filterID)", body: ParametersBody([
|
||||||
|
"title" => title,
|
||||||
|
"expires_in" => expiresIn,
|
||||||
|
"filter_action" => action.rawValue,
|
||||||
|
] + "context" => context.contextStrings + keywordsParams))
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func create(
|
||||||
|
title: String,
|
||||||
|
context: [FilterV1.Context],
|
||||||
|
expiresIn: TimeInterval?,
|
||||||
|
action: Action,
|
||||||
|
keywords keywordUpdates: [KeywordUpdate]
|
||||||
|
) -> Request<FilterV2> {
|
||||||
|
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,
|
||||||
|
"filter_action" => action.rawValue,
|
||||||
|
] + "context" => context.contextStrings + keywordsParams))
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case title
|
||||||
|
case context
|
||||||
|
case expiresAt = "expires_at"
|
||||||
|
case action = "filter_action"
|
||||||
|
case keywords
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FilterV2 {
|
||||||
|
public enum Action: String, Decodable, Hashable, CaseIterable {
|
||||||
|
case warn
|
||||||
|
case hide
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FilterV2 {
|
||||||
|
public struct Keyword: Decodable {
|
||||||
|
public let id: String
|
||||||
|
public let keyword: String
|
||||||
|
public let wholeWord: Bool
|
||||||
|
|
||||||
|
public init(id: String, keyword: String, wholeWord: Bool) {
|
||||||
|
self.id = id
|
||||||
|
self.keyword = keyword
|
||||||
|
self.wholeWord = wholeWord
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case keyword
|
||||||
|
case wholeWord = "whole_word"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FilterV2 {
|
||||||
|
public enum KeywordUpdate {
|
||||||
|
case update(id: String, keyword: String, wholeWord: Bool)
|
||||||
|
case add(keyword: String, wholeWord: Bool)
|
||||||
|
case destroy(id: String)
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum Timeline {
|
public enum Timeline: Equatable {
|
||||||
case home
|
case home
|
||||||
case `public`(local: Bool)
|
case `public`(local: Bool)
|
||||||
case tag(hashtag: String)
|
case tag(hashtag: String)
|
||||||
|
|
|
@ -42,6 +42,10 @@ extension String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func =>(name: String, value: TimeInterval?) -> Parameter {
|
||||||
|
return name => (value == nil ? nil : Int(value!))
|
||||||
|
}
|
||||||
|
|
||||||
static func =>(name: String, focus: (Float, Float)?) -> Parameter {
|
static func =>(name: String, focus: (Float, Float)?) -> Parameter {
|
||||||
guard let focus = focus else { return Parameter(name: name, value: nil) }
|
guard let focus = focus else { return Parameter(name: name, value: nil) }
|
||||||
return Parameter(name: name, value: "\(focus.0),\(focus.1)")
|
return Parameter(name: name, value: "\(focus.0),\(focus.1)")
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// StatusState.swift
|
// CollapseState.swift
|
||||||
// Pachyderm
|
// Pachyderm
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 11/24/19.
|
// Created by Shadowfacts on 11/24/19.
|
||||||
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class StatusState: Equatable {
|
public class CollapseState: Equatable {
|
||||||
public var collapsible: Bool?
|
public var collapsible: Bool?
|
||||||
public var collapsed: Bool?
|
public var collapsed: Bool?
|
||||||
|
|
||||||
|
@ -21,8 +21,8 @@ public class StatusState: Equatable {
|
||||||
self.collapsed = collapsed
|
self.collapsed = collapsed
|
||||||
}
|
}
|
||||||
|
|
||||||
public func copy() -> StatusState {
|
public func copy() -> CollapseState {
|
||||||
return StatusState(collapsible: self.collapsible, collapsed: self.collapsed)
|
return CollapseState(collapsible: self.collapsible, collapsed: self.collapsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func hash(into hasher: inout Hasher) {
|
public func hash(into hasher: inout Hasher) {
|
||||||
|
@ -30,11 +30,11 @@ public class StatusState: Equatable {
|
||||||
hasher.combine(collapsed)
|
hasher.combine(collapsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static var unknown: StatusState {
|
public static var unknown: CollapseState {
|
||||||
StatusState(collapsible: nil, collapsed: nil)
|
CollapseState(collapsible: nil, collapsed: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func == (lhs: StatusState, rhs: StatusState) -> Bool {
|
public static func == (lhs: CollapseState, rhs: CollapseState) -> Bool {
|
||||||
lhs.collapsible == rhs.collapsible && lhs.collapsed == rhs.collapsed
|
lhs.collapsible == rhs.collapsible && lhs.collapsed == rhs.collapsed
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -12,7 +12,7 @@ public struct NotificationGroup: Identifiable, Hashable {
|
||||||
public private(set) var notifications: [Notification]
|
public private(set) var notifications: [Notification]
|
||||||
public let id: String
|
public let id: String
|
||||||
public let kind: Notification.Kind
|
public let kind: Notification.Kind
|
||||||
public let statusState: StatusState?
|
public let statusState: CollapseState?
|
||||||
|
|
||||||
init?(notifications: [Notification]) {
|
init?(notifications: [Notification]) {
|
||||||
guard !notifications.isEmpty else { return nil }
|
guard !notifications.isEmpty else { return nil }
|
||||||
|
|
|
@ -52,6 +52,21 @@
|
||||||
D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759129365C6C00C0B37F /* CollectionViewController.swift */; };
|
D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759129365C6C00C0B37F /* CollectionViewController.swift */; };
|
||||||
D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */; };
|
D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */; };
|
||||||
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */; };
|
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */; };
|
||||||
|
D61F759929384D4D00C0B37F /* FiltersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759829384D4D00C0B37F /* FiltersView.swift */; };
|
||||||
|
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759A29384F9C00C0B37F /* FilterMO.swift */; };
|
||||||
|
D61F759D2938574B00C0B37F /* FilterRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759C2938574B00C0B37F /* FilterRow.swift */; };
|
||||||
|
D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */; };
|
||||||
|
D61F75A129396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */; };
|
||||||
|
D61F75A5293ABD6F00C0B37F /* EditFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75A4293ABD6F00C0B37F /* EditFilterView.swift */; };
|
||||||
|
D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */; };
|
||||||
|
D61F75AD293AF39000C0B37F /* Filter+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75AC293AF39000C0B37F /* Filter+Helpers.swift */; };
|
||||||
|
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75AE293AF50C00C0B37F /* EditedFilter.swift */; };
|
||||||
|
D61F75B1293BD85300C0B37F /* CreateFilterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B0293BD85300C0B37F /* CreateFilterService.swift */; };
|
||||||
|
D61F75B3293BD89C00C0B37F /* UpdateFilterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B2293BD89C00C0B37F /* UpdateFilterService.swift */; };
|
||||||
|
D61F75B5293BD97400C0B37F /* DeleteFilterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */; };
|
||||||
|
D61F75B7293C119700C0B37F /* Filterer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B6293C119700C0B37F /* Filterer.swift */; };
|
||||||
|
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */; };
|
||||||
|
D61F75BB293C183100C0B37F /* HTMLConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75BA293C183100C0B37F /* HTMLConverter.swift */; };
|
||||||
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
|
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
|
||||||
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
|
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
|
||||||
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
|
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
|
||||||
|
@ -420,6 +435,21 @@
|
||||||
D61F759129365C6C00C0B37F /* CollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewController.swift; sourceTree = "<group>"; };
|
D61F759129365C6C00C0B37F /* CollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewController.swift; sourceTree = "<group>"; };
|
||||||
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedHashtag.swift; sourceTree = "<group>"; };
|
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedHashtag.swift; sourceTree = "<group>"; };
|
||||||
D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleFollowHashtagService.swift; sourceTree = "<group>"; };
|
D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleFollowHashtagService.swift; sourceTree = "<group>"; };
|
||||||
|
D61F759829384D4D00C0B37F /* FiltersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersView.swift; sourceTree = "<group>"; };
|
||||||
|
D61F759A29384F9C00C0B37F /* FilterMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterMO.swift; sourceTree = "<group>"; };
|
||||||
|
D61F759C2938574B00C0B37F /* FilterRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterRow.swift; sourceTree = "<group>"; };
|
||||||
|
D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemiCaseSensitiveComparator.swift; sourceTree = "<group>"; };
|
||||||
|
D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemiCaseSensitiveComparatorTests.swift; sourceTree = "<group>"; };
|
||||||
|
D61F75A4293ABD6F00C0B37F /* EditFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditFilterView.swift; sourceTree = "<group>"; };
|
||||||
|
D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterKeywordMO.swift; sourceTree = "<group>"; };
|
||||||
|
D61F75AC293AF39000C0B37F /* Filter+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Filter+Helpers.swift"; sourceTree = "<group>"; };
|
||||||
|
D61F75AE293AF50C00C0B37F /* EditedFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditedFilter.swift; sourceTree = "<group>"; };
|
||||||
|
D61F75B0293BD85300C0B37F /* CreateFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateFilterService.swift; sourceTree = "<group>"; };
|
||||||
|
D61F75B2293BD89C00C0B37F /* UpdateFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateFilterService.swift; sourceTree = "<group>"; };
|
||||||
|
D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteFilterService.swift; sourceTree = "<group>"; };
|
||||||
|
D61F75B6293C119700C0B37F /* Filterer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Filterer.swift; sourceTree = "<group>"; };
|
||||||
|
D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZeroHeightCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
|
D61F75BA293C183100C0B37F /* HTMLConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLConverter.swift; sourceTree = "<group>"; };
|
||||||
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
|
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
|
||||||
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
|
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
|
||||||
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
|
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
|
||||||
|
@ -775,6 +805,7 @@
|
||||||
D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */,
|
D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */,
|
||||||
D677284D24ECC01D00C732D3 /* Draft.swift */,
|
D677284D24ECC01D00C732D3 /* Draft.swift */,
|
||||||
D627FF75217E923E00CC0648 /* DraftsManager.swift */,
|
D627FF75217E923E00CC0648 /* DraftsManager.swift */,
|
||||||
|
D61F75AE293AF50C00C0B37F /* EditedFilter.swift */,
|
||||||
);
|
);
|
||||||
path = Models;
|
path = Models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -788,6 +819,16 @@
|
||||||
path = "Instance Cell";
|
path = "Instance Cell";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
D61F759729384D4200C0B37F /* Filters */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D61F759829384D4D00C0B37F /* FiltersView.swift */,
|
||||||
|
D61F759C2938574B00C0B37F /* FilterRow.swift */,
|
||||||
|
D61F75A4293ABD6F00C0B37F /* EditFilterView.swift */,
|
||||||
|
);
|
||||||
|
path = Filters;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
D623A53B2635F4E20095BD04 /* Poll */ = {
|
D623A53B2635F4E20095BD04 /* Poll */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -886,6 +927,8 @@
|
||||||
D6B9366E2828452F00237D0E /* SavedHashtag.swift */,
|
D6B9366E2828452F00237D0E /* SavedHashtag.swift */,
|
||||||
D6B9366C2828444F00237D0E /* SavedInstance.swift */,
|
D6B9366C2828444F00237D0E /* SavedInstance.swift */,
|
||||||
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */,
|
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */,
|
||||||
|
D61F759A29384F9C00C0B37F /* FilterMO.swift */,
|
||||||
|
D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */,
|
||||||
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
|
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
|
||||||
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
|
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
|
||||||
);
|
);
|
||||||
|
@ -915,6 +958,7 @@
|
||||||
D6F2E960249E772F005846BB /* Crash Reporter */,
|
D6F2E960249E772F005846BB /* Crash Reporter */,
|
||||||
D627943C23A5635D00D38C68 /* Explore */,
|
D627943C23A5635D00D38C68 /* Explore */,
|
||||||
D6A4DCC92553666600D9DE31 /* Fast Account Switcher */,
|
D6A4DCC92553666600D9DE31 /* Fast Account Switcher */,
|
||||||
|
D61F759729384D4200C0B37F /* Filters */,
|
||||||
D641C788213DD86D004B4513 /* Large Image */,
|
D641C788213DD86D004B4513 /* Large Image */,
|
||||||
D627944B23A9A02400D38C68 /* Lists */,
|
D627944B23A9A02400D38C68 /* Lists */,
|
||||||
D641C782213DD7F0004B4513 /* Main */,
|
D641C782213DD7F0004B4513 /* Main */,
|
||||||
|
@ -1176,6 +1220,7 @@
|
||||||
D6ADB6ED28EA74E8009924AB /* UIView+Configure.swift */,
|
D6ADB6ED28EA74E8009924AB /* UIView+Configure.swift */,
|
||||||
D63CC70F2911F1E4000E19DE /* UIScrollView+Top.swift */,
|
D63CC70F2911F1E4000E19DE /* UIScrollView+Top.swift */,
|
||||||
D61F758F29353B4300C0B37F /* FileManager+Size.swift */,
|
D61F758F29353B4300C0B37F /* FileManager+Size.swift */,
|
||||||
|
D61F75AC293AF39000C0B37F /* Filter+Helpers.swift */,
|
||||||
);
|
);
|
||||||
path = Extensions;
|
path = Extensions;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1306,6 +1351,7 @@
|
||||||
D620483523D38075008A63EF /* ContentTextView.swift */,
|
D620483523D38075008A63EF /* ContentTextView.swift */,
|
||||||
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
|
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
|
||||||
D6969E9F240C8384002843CE /* EmojiLabel.swift */,
|
D6969E9F240C8384002843CE /* EmojiLabel.swift */,
|
||||||
|
D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */,
|
||||||
D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */,
|
D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */,
|
||||||
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */,
|
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */,
|
||||||
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */,
|
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */,
|
||||||
|
@ -1413,7 +1459,9 @@
|
||||||
D6D4DDDB212518A200E1C4BB /* Info.plist */,
|
D6D4DDDB212518A200E1C4BB /* Info.plist */,
|
||||||
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
|
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
|
||||||
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
|
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
|
||||||
|
D61F75B6293C119700C0B37F /* Filterer.swift */,
|
||||||
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
|
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
|
||||||
|
D61F75BA293C183100C0B37F /* HTMLConverter.swift */,
|
||||||
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */,
|
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */,
|
||||||
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
|
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
|
||||||
D64D0AAC2128D88B005A6F37 /* LocalData.swift */,
|
D64D0AAC2128D88B005A6F37 /* LocalData.swift */,
|
||||||
|
@ -1421,6 +1469,7 @@
|
||||||
D6B81F432560390300F6E31D /* MenuController.swift */,
|
D6B81F432560390300F6E31D /* MenuController.swift */,
|
||||||
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
|
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
|
||||||
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
|
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
|
||||||
|
D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */,
|
||||||
D6895DE828D962C2006341DA /* TimelineLikeController.swift */,
|
D6895DE828D962C2006341DA /* TimelineLikeController.swift */,
|
||||||
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */,
|
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */,
|
||||||
D6DFC69F242C4CCC00ACC392 /* Weak.swift */,
|
D6DFC69F242C4CCC00ACC392 /* Weak.swift */,
|
||||||
|
@ -1451,6 +1500,7 @@
|
||||||
D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */,
|
D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */,
|
||||||
D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */,
|
D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */,
|
||||||
D6114E1627F8BB210080E273 /* VersionTests.swift */,
|
D6114E1627F8BB210080E273 /* VersionTests.swift */,
|
||||||
|
D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */,
|
||||||
D6D4DDE6212518A200E1C4BB /* Info.plist */,
|
D6D4DDE6212518A200E1C4BB /* Info.plist */,
|
||||||
);
|
);
|
||||||
path = TuskerTests;
|
path = TuskerTests;
|
||||||
|
@ -1532,6 +1582,9 @@
|
||||||
D6F6A551291F098700F496A8 /* RenameListService.swift */,
|
D6F6A551291F098700F496A8 /* RenameListService.swift */,
|
||||||
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */,
|
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */,
|
||||||
D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */,
|
D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */,
|
||||||
|
D61F75B0293BD85300C0B37F /* CreateFilterService.swift */,
|
||||||
|
D61F75B2293BD89C00C0B37F /* UpdateFilterService.swift */,
|
||||||
|
D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */,
|
||||||
);
|
);
|
||||||
path = API;
|
path = API;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1812,6 +1865,8 @@
|
||||||
D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */,
|
D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */,
|
||||||
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */,
|
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */,
|
||||||
D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */,
|
D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */,
|
||||||
|
D61F75BB293C183100C0B37F /* HTMLConverter.swift in Sources */,
|
||||||
|
D61F75A5293ABD6F00C0B37F /* EditFilterView.swift in Sources */,
|
||||||
D6AEBB412321642700E5038B /* SendMesasgeActivity.swift in Sources */,
|
D6AEBB412321642700E5038B /* SendMesasgeActivity.swift in Sources */,
|
||||||
D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */,
|
D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */,
|
||||||
D6ADB6EC28EA73CB009924AB /* StatusContentContainer.swift in Sources */,
|
D6ADB6EC28EA73CB009924AB /* StatusContentContainer.swift in Sources */,
|
||||||
|
@ -1824,6 +1879,7 @@
|
||||||
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */,
|
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */,
|
||||||
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */,
|
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */,
|
||||||
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */,
|
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */,
|
||||||
|
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */,
|
||||||
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
|
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
|
||||||
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */,
|
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */,
|
||||||
D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */,
|
D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */,
|
||||||
|
@ -1866,6 +1922,7 @@
|
||||||
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */,
|
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */,
|
||||||
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
|
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
|
||||||
D63CC70C2910AADB000E19DE /* TuskerSceneDelegate.swift in Sources */,
|
D63CC70C2910AADB000E19DE /* TuskerSceneDelegate.swift in Sources */,
|
||||||
|
D61F759D2938574B00C0B37F /* FilterRow.swift in Sources */,
|
||||||
D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */,
|
D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */,
|
||||||
D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */,
|
D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */,
|
||||||
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */,
|
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */,
|
||||||
|
@ -1893,6 +1950,7 @@
|
||||||
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,
|
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,
|
||||||
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
|
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
|
||||||
D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */,
|
D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */,
|
||||||
|
D61F75AD293AF39000C0B37F /* Filter+Helpers.swift in Sources */,
|
||||||
D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */,
|
D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */,
|
||||||
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */,
|
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */,
|
||||||
D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */,
|
D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */,
|
||||||
|
@ -1910,6 +1968,7 @@
|
||||||
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */,
|
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */,
|
||||||
D6ADB6EE28EA74E8009924AB /* UIView+Configure.swift in Sources */,
|
D6ADB6EE28EA74E8009924AB /* UIView+Configure.swift in Sources */,
|
||||||
D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */,
|
D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */,
|
||||||
|
D61F75B1293BD85300C0B37F /* CreateFilterService.swift in Sources */,
|
||||||
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */,
|
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */,
|
||||||
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */,
|
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */,
|
||||||
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */,
|
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */,
|
||||||
|
@ -1939,6 +1998,7 @@
|
||||||
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */,
|
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */,
|
||||||
D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */,
|
D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */,
|
||||||
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */,
|
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */,
|
||||||
|
D61F75B7293C119700C0B37F /* Filterer.swift in Sources */,
|
||||||
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */,
|
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */,
|
||||||
D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */,
|
D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */,
|
||||||
D6420AEE26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift in Sources */,
|
D6420AEE26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift in Sources */,
|
||||||
|
@ -1966,6 +2026,7 @@
|
||||||
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */,
|
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */,
|
||||||
D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */,
|
D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */,
|
||||||
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
|
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
|
||||||
|
D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */,
|
||||||
D663626221360B1900C9CBA2 /* Preferences.swift in Sources */,
|
D663626221360B1900C9CBA2 /* Preferences.swift in Sources */,
|
||||||
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
|
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
|
||||||
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
|
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
|
||||||
|
@ -2002,6 +2063,8 @@
|
||||||
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
|
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
|
||||||
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
|
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
|
||||||
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */,
|
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */,
|
||||||
|
D61F759929384D4D00C0B37F /* FiltersView.swift in Sources */,
|
||||||
|
D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */,
|
||||||
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */,
|
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */,
|
||||||
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */,
|
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */,
|
||||||
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */,
|
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */,
|
||||||
|
@ -2013,6 +2076,7 @@
|
||||||
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */,
|
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */,
|
||||||
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
|
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
|
||||||
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */,
|
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */,
|
||||||
|
D61F75B5293BD97400C0B37F /* DeleteFilterService.swift in Sources */,
|
||||||
D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */,
|
D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */,
|
||||||
D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */,
|
D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */,
|
||||||
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */,
|
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */,
|
||||||
|
@ -2024,6 +2088,8 @@
|
||||||
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */,
|
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */,
|
||||||
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */,
|
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */,
|
||||||
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
|
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
|
||||||
|
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */,
|
||||||
|
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */,
|
||||||
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
|
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
|
||||||
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
|
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
|
||||||
D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */,
|
D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */,
|
||||||
|
@ -2058,6 +2124,7 @@
|
||||||
D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */,
|
D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */,
|
||||||
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
|
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
|
||||||
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
|
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
|
||||||
|
D61F75B3293BD89C00C0B37F /* UpdateFilterService.swift in Sources */,
|
||||||
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
|
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
|
||||||
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */,
|
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */,
|
||||||
D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */,
|
D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */,
|
||||||
|
@ -2074,6 +2141,7 @@
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
D61F75A129396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift in Sources */,
|
||||||
D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */,
|
D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */,
|
||||||
D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */,
|
D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */,
|
||||||
D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */,
|
D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */,
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
//
|
||||||
|
// CreateFilterService.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/3/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class CreateFilterService {
|
||||||
|
private let filter: EditedFilter
|
||||||
|
private let mastodonController: MastodonController
|
||||||
|
|
||||||
|
init(filter: EditedFilter, mastodonController: MastodonController) {
|
||||||
|
self.filter = filter
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
//
|
||||||
|
// DeleteFilterService.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/3/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class DeleteFilterService {
|
||||||
|
private let filter: FilterMO
|
||||||
|
private let mastodonController: MastodonController
|
||||||
|
|
||||||
|
init(filter: FilterMO, mastodonController: MastodonController) {
|
||||||
|
self.filter = filter
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
let req = FilterV1.delete(filter.id)
|
||||||
|
_ = try await mastodonController.run(req)
|
||||||
|
let context = mastodonController.persistentContainer.viewContext
|
||||||
|
context.delete(filter)
|
||||||
|
mastodonController.persistentContainer.save(context: context)
|
||||||
|
}
|
||||||
|
}
|
|
@ -51,11 +51,11 @@ struct InstanceFeatures {
|
||||||
}
|
}
|
||||||
|
|
||||||
var trendingStatusesAndLinks: Bool {
|
var trendingStatusesAndLinks: Bool {
|
||||||
instanceType.isMastodon && hasVersion(3, 5, 0)
|
instanceType.isMastodon && hasMastodonVersion(3, 5, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
var reblogVisibility: Bool {
|
var reblogVisibility: Bool {
|
||||||
(instanceType.isMastodon && hasVersion(2, 8, 0))
|
(instanceType.isMastodon && hasMastodonVersion(2, 8, 0))
|
||||||
|| (instanceType.isPleroma && hasPleromaVersion(2, 0, 0))
|
|| (instanceType.isPleroma && hasPleromaVersion(2, 0, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -85,11 +85,11 @@ struct InstanceFeatures {
|
||||||
}
|
}
|
||||||
|
|
||||||
var canFollowHashtags: Bool {
|
var canFollowHashtags: Bool {
|
||||||
if case .mastodon(_, .some(let version)) = instanceType {
|
hasMastodonVersion(4, 0, 0)
|
||||||
return version >= Version(4, 0, 0)
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var filtersV2: Bool {
|
||||||
|
hasMastodonVersion(4, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func update(instance: Instance, nodeInfo: NodeInfo?) {
|
mutating func update(instance: Instance, nodeInfo: NodeInfo?) {
|
||||||
|
@ -138,7 +138,7 @@ struct InstanceFeatures {
|
||||||
maxStatusChars = instance.maxStatusCharacters ?? 500
|
maxStatusChars = instance.maxStatusCharacters ?? 500
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
|
func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
|
||||||
if case .mastodon(_, .some(let version)) = instanceType {
|
if case .mastodon(_, .some(let version)) = instanceType {
|
||||||
return version >= Version(major, minor, patch)
|
return version >= Version(major, minor, patch)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -50,6 +50,7 @@ class MastodonController: ObservableObject {
|
||||||
@Published private(set) var lists: [List] = []
|
@Published private(set) var lists: [List] = []
|
||||||
@Published private(set) var customEmojis: [Emoji]?
|
@Published private(set) var customEmojis: [Emoji]?
|
||||||
@Published private(set) var followedHashtags: [FollowedHashtag] = []
|
@Published private(set) var followedHashtags: [FollowedHashtag] = []
|
||||||
|
@Published private(set) var filters: [FilterMO] = []
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
@ -147,13 +148,25 @@ class MastodonController: ObservableObject {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func initialize() async throws {
|
@MainActor
|
||||||
|
func initialize() {
|
||||||
|
// we want this to happen immediately, and synchronously so that the filters (which don't change that often)
|
||||||
|
// are available when Filterers are constructed
|
||||||
|
loadCachedFilters()
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
async let ownAccount = try getOwnAccount()
|
async let ownAccount = try getOwnAccount()
|
||||||
async let ownInstance = try getOwnInstance()
|
async let ownInstance = try getOwnInstance()
|
||||||
|
|
||||||
_ = try await (ownAccount, ownInstance)
|
_ = try await (ownAccount, ownInstance)
|
||||||
|
|
||||||
loadLists()
|
loadLists()
|
||||||
|
async let _ = await loadFilters()
|
||||||
|
} catch {
|
||||||
|
Logging.general.error("MastodonController initialization failed: \(String(describing: error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) {
|
func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) {
|
||||||
|
@ -302,7 +315,7 @@ class MastodonController: ObservableObject {
|
||||||
run(req) { response in
|
run(req) { response in
|
||||||
if case .success(let lists, _) = response {
|
if case .success(let lists, _) = response {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.lists = lists.sorted(using: ListComparator())
|
self.lists = lists.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -312,7 +325,7 @@ class MastodonController: ObservableObject {
|
||||||
func addedList(_ list: List) {
|
func addedList(_ list: List) {
|
||||||
var new = self.lists
|
var new = self.lists
|
||||||
new.append(list)
|
new.append(list)
|
||||||
new.sort { $0.title < $1.title }
|
new.sort(using: SemiCaseSensitiveComparator.keyPath(\.title))
|
||||||
self.lists = new
|
self.lists = new
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -327,7 +340,7 @@ class MastodonController: ObservableObject {
|
||||||
if let index = new.firstIndex(where: { $0.id == list.id }) {
|
if let index = new.firstIndex(where: { $0.id == list.id }) {
|
||||||
new[index] = list
|
new[index] = list
|
||||||
}
|
}
|
||||||
new.sort(using: ListComparator())
|
new.sort(using: SemiCaseSensitiveComparator.keyPath(\.title))
|
||||||
self.lists = new
|
self.lists = new
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -350,19 +363,32 @@ class MastodonController: ObservableObject {
|
||||||
followedHashtags = (try? persistentContainer.viewContext.fetch(FollowedHashtag.fetchRequest())) ?? []
|
followedHashtags = (try? persistentContainer.viewContext.fetch(FollowedHashtag.fetchRequest())) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func loadFilters() async {
|
||||||
|
var apiFilters: [AnyFilter]?
|
||||||
|
if instanceFeatures.filtersV2 {
|
||||||
|
let req = Client.getFiltersV2()
|
||||||
|
if let (filters, _) = try? await run(req) {
|
||||||
|
apiFilters = filters.map { .v2($0) }
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
private struct ListComparator: SortComparator {
|
let req = Client.getFiltersV1()
|
||||||
typealias Compared = List
|
if let (filters, _) = try? await run(req) {
|
||||||
|
apiFilters = filters.map { .v1($0) }
|
||||||
var underlying = String.Comparator(options: .caseInsensitive)
|
|
||||||
|
|
||||||
var order: SortOrder {
|
|
||||||
get { underlying.order }
|
|
||||||
set { underlying.order = newValue }
|
|
||||||
}
|
|
||||||
|
|
||||||
func compare(_ lhs: List, _ rhs: List) -> ComparisonResult {
|
|
||||||
return underlying.compare(lhs.title, rhs.title)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let apiFilters {
|
||||||
|
self.persistentContainer.updateFilters(apiFilters) {
|
||||||
|
if case .success(let filters) = $0 {
|
||||||
|
self.filters = filters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func loadCachedFilters() {
|
||||||
|
filters = (try? persistentContainer.viewContext.fetch(FilterMO.fetchRequest())) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
//
|
||||||
|
// UpdateFilterService.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/3/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class UpdateFilterService {
|
||||||
|
private let filter: EditedFilter
|
||||||
|
private let mastodonController: MastodonController
|
||||||
|
|
||||||
|
init(filter: EditedFilter, mastodonController: MastodonController) {
|
||||||
|
self.filter = filter
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
let context = mastodonController.persistentContainer.viewContext
|
||||||
|
let mo = try context.fetch(FilterMO.fetchRequest(id: filter.id!)).first!
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
mo.updateFrom(apiFilter: updateFrom, context: context)
|
||||||
|
mastodonController.persistentContainer.save(context: context)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
//
|
||||||
|
// FilterKeywordMO.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/2/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
@objc(FilterKeywordMO)
|
||||||
|
public final class FilterKeywordMO: NSManagedObject {
|
||||||
|
|
||||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<FilterKeywordMO> {
|
||||||
|
return NSFetchRequest(entityName: "FilterKeyword")
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged public var id: String?
|
||||||
|
@NSManaged public var keyword: String
|
||||||
|
@NSManaged public var wholeWord: Bool
|
||||||
|
@NSManaged public var filter: FilterMO
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FilterKeywordMO {
|
||||||
|
convenience init(apiKeyword keyword: FilterV2.Keyword, context: NSManagedObjectContext) {
|
||||||
|
self.init(context: context)
|
||||||
|
self.updateFrom(apiKeyword: keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFrom(apiKeyword keyword: FilterV2.Keyword) {
|
||||||
|
self.id = keyword.id
|
||||||
|
self.keyword = keyword.keyword
|
||||||
|
self.wholeWord = keyword.wholeWord
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,140 @@
|
||||||
|
//
|
||||||
|
// FilterMO.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/30/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
@objc(FilterMO)
|
||||||
|
public final class FilterMO: NSManagedObject {
|
||||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<FilterMO> {
|
||||||
|
return NSFetchRequest(entityName: "Filter")
|
||||||
|
}
|
||||||
|
|
||||||
|
@nonobjc public class func fetchRequest(id: String) -> NSFetchRequest<FilterMO> {
|
||||||
|
let req = NSFetchRequest<FilterMO>(entityName: "Filter")
|
||||||
|
req.predicate = NSPredicate(format: "id = %@", id)
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged public var id: String
|
||||||
|
@NSManaged public var title: String?
|
||||||
|
@NSManaged private var context: String
|
||||||
|
@NSManaged public var expiresAt: Date?
|
||||||
|
@NSManaged public var keywords: NSMutableSet
|
||||||
|
@NSManaged public var action: String
|
||||||
|
|
||||||
|
private var _contexts: [FilterV1.Context]?
|
||||||
|
public var contexts: [FilterV1.Context] {
|
||||||
|
get {
|
||||||
|
if let _contexts {
|
||||||
|
return _contexts
|
||||||
|
} else {
|
||||||
|
_contexts = context.split(separator: ",").compactMap { .init(rawValue: String($0)) }
|
||||||
|
return _contexts!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
_contexts = newValue
|
||||||
|
context = newValue.map(\.rawValue).joined(separator: ",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var keywordMOs: [FilterKeywordMO] {
|
||||||
|
keywords.allObjects as! [FilterKeywordMO]
|
||||||
|
}
|
||||||
|
|
||||||
|
var titleOrKeyword: String {
|
||||||
|
title ?? (keywords.allObjects.first! as! FilterKeywordMO).keyword
|
||||||
|
}
|
||||||
|
|
||||||
|
var filterAction: FilterV2.Action {
|
||||||
|
get {
|
||||||
|
FilterV2.Action(rawValue: action)!
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
action = newValue.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func didChangeValue(forKey key: String) {
|
||||||
|
super.didChangeValue(forKey: key)
|
||||||
|
if key == "context" {
|
||||||
|
_contexts = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FilterMO {
|
||||||
|
func updateFrom(apiFilter filter: AnyFilter, context: NSManagedObjectContext) {
|
||||||
|
switch filter {
|
||||||
|
case .v1(let v1):
|
||||||
|
self.updateFrom(apiFilter: v1, context: context)
|
||||||
|
case .v2(let v2):
|
||||||
|
self.updateFrom(apiFilter: v2, context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFrom(apiFilter filter: FilterV1, context: NSManagedObjectContext) {
|
||||||
|
self.id = filter.id
|
||||||
|
self.title = nil
|
||||||
|
self.contexts = filter.contexts
|
||||||
|
self.expiresAt = filter.expiresAt
|
||||||
|
self.filterAction = .warn
|
||||||
|
let keyword: FilterKeywordMO
|
||||||
|
if self.keywords.count == 0 {
|
||||||
|
keyword = FilterKeywordMO(context: context)
|
||||||
|
keyword.filter = self
|
||||||
|
self.keywords.add(keyword)
|
||||||
|
} else {
|
||||||
|
keyword = self.keywords.allObjects.first! as! FilterKeywordMO
|
||||||
|
}
|
||||||
|
keyword.keyword = filter.phrase
|
||||||
|
keyword.wholeWord = filter.wholeWord
|
||||||
|
keyword.id = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFrom(apiFilter filter: FilterV2, context: NSManagedObjectContext) {
|
||||||
|
self.id = filter.id
|
||||||
|
self.title = filter.title
|
||||||
|
self.contexts = filter.context
|
||||||
|
self.expiresAt = filter.expiresAt
|
||||||
|
self.filterAction = filter.action
|
||||||
|
|
||||||
|
var existing = keywordMOs
|
||||||
|
for keyword in filter.keywords {
|
||||||
|
if let mo = existing.first(where: { $0.id == keyword.id }) {
|
||||||
|
mo.updateFrom(apiKeyword: keyword)
|
||||||
|
existing.removeAll(where: { $0.id == keyword.id })
|
||||||
|
} else {
|
||||||
|
let mo = FilterKeywordMO(context: context)
|
||||||
|
mo.updateFrom(apiKeyword: keyword)
|
||||||
|
mo.filter = self
|
||||||
|
self.keywords.add(mo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for unupdated in existing {
|
||||||
|
context.delete(unupdated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AnyFilter {
|
||||||
|
case v1(FilterV1)
|
||||||
|
case v2(FilterV2)
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .v1(let v1):
|
||||||
|
return v1.id
|
||||||
|
case .v2(let v2):
|
||||||
|
return v2.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -299,6 +299,34 @@ class MastodonCachePersistentStore: NSPersistentContainer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateFilters(_ filters: [AnyFilter], completion: @escaping (Result<[FilterMO], Error>) -> Void) {
|
||||||
|
viewContext.perform {
|
||||||
|
do {
|
||||||
|
var all = try self.viewContext.fetch(FilterMO.fetchRequest())
|
||||||
|
|
||||||
|
let toDelete = all.filter { existing in !filters.contains(where: { $0.id == existing.id }) }.map(\.objectID)
|
||||||
|
if !toDelete.isEmpty {
|
||||||
|
try self.viewContext.execute(NSBatchDeleteRequest(objectIDs: toDelete))
|
||||||
|
}
|
||||||
|
|
||||||
|
for filter in filters {
|
||||||
|
if let existing = all.first(where: { $0.id == filter.id }) {
|
||||||
|
existing.updateFrom(apiFilter: filter, context: self.viewContext)
|
||||||
|
} else {
|
||||||
|
let mo = FilterMO(context: self.viewContext)
|
||||||
|
mo.updateFrom(apiFilter: filter, context: self.viewContext)
|
||||||
|
all.append(mo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.save(context: self.viewContext)
|
||||||
|
completion(.success(all))
|
||||||
|
} catch {
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {
|
@objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {
|
||||||
let changes = hasChangedSavedHashtagsOrInstances(notification)
|
let changes = hasChangedSavedHashtagsOrInstances(notification)
|
||||||
if changes.hashtags {
|
if changes.hashtags {
|
||||||
|
|
|
@ -28,6 +28,20 @@
|
||||||
</uniquenessConstraint>
|
</uniquenessConstraint>
|
||||||
</uniquenessConstraints>
|
</uniquenessConstraints>
|
||||||
</entity>
|
</entity>
|
||||||
|
<entity name="Filter" representedClassName="FilterMO" syncable="YES">
|
||||||
|
<attribute name="action" attributeType="String" defaultValueString="warn"/>
|
||||||
|
<attribute name="context" attributeType="String"/>
|
||||||
|
<attribute name="expiresAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="id" attributeType="String"/>
|
||||||
|
<attribute name="title" optional="YES" attributeType="String"/>
|
||||||
|
<relationship name="keywords" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="FilterKeyword" inverseName="filter" inverseEntity="FilterKeyword"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="FilterKeyword" representedClassName="FilterKeywordMO" syncable="YES">
|
||||||
|
<attribute name="id" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="keyword" attributeType="String"/>
|
||||||
|
<attribute name="wholeWord" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
|
<relationship name="filter" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Filter" inverseName="keywords" inverseEntity="Filter"/>
|
||||||
|
</entity>
|
||||||
<entity name="FollowedHashtag" representedClassName="FollowedHashtag" syncable="YES">
|
<entity name="FollowedHashtag" representedClassName="FollowedHashtag" syncable="YES">
|
||||||
<attribute name="name" attributeType="String"/>
|
<attribute name="name" attributeType="String"/>
|
||||||
<attribute name="url" attributeType="URI"/>
|
<attribute name="url" attributeType="URI"/>
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
//
|
||||||
|
// Filter+Helpers.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/2/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
extension FilterV1.Context {
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .home:
|
||||||
|
return "Home and lists"
|
||||||
|
case .notifications:
|
||||||
|
return "Notifications"
|
||||||
|
case .public:
|
||||||
|
return "Public timelines"
|
||||||
|
case .thread:
|
||||||
|
return "Conversations"
|
||||||
|
case .account:
|
||||||
|
return "Profiles"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FilterV2.Action {
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .warn:
|
||||||
|
return "Warn"
|
||||||
|
case .hide:
|
||||||
|
return "Hide"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,7 +9,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
extension StatusState {
|
extension CollapseState {
|
||||||
|
|
||||||
func resolveFor(status: StatusMO, height: CGFloat, textLength: Int? = nil) {
|
func resolveFor(status: StatusMO, height: CGFloat, textLength: Int? = nil) {
|
||||||
let longEnoughToCollapse: Bool
|
let longEnoughToCollapse: Bool
|
||||||
|
|
|
@ -0,0 +1,199 @@
|
||||||
|
//
|
||||||
|
// Filterer.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/3/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Pachyderm
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// An opaque object that serves as the cache for the filtered-ness of a particular status.
|
||||||
|
class FilterState {
|
||||||
|
static var unknown: FilterState { FilterState(state: .unknown) }
|
||||||
|
|
||||||
|
fileprivate var state: State
|
||||||
|
|
||||||
|
var isWarning: Bool {
|
||||||
|
switch state {
|
||||||
|
case .known(.warn(_), _):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private init(state: State) {
|
||||||
|
self.state = state
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate enum State {
|
||||||
|
case unknown
|
||||||
|
case known(Filterer.Result, generation: Int)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class Filterer {
|
||||||
|
let mastodonController: MastodonController
|
||||||
|
let context: FilterV1.Context
|
||||||
|
|
||||||
|
var filtersChanged: ((Bool) -> Void)?
|
||||||
|
|
||||||
|
private let htmlConverter = HTMLConverter()
|
||||||
|
private var hasSetup = false
|
||||||
|
private var matchers = [(NSRegularExpression, Result)]()
|
||||||
|
private var allFiltersObserver: AnyCancellable?
|
||||||
|
private var filterObservers = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// the generation is incremented when the matchers change, to indicate that older cached FilterStates
|
||||||
|
// are no longer valid, without needing to go through and update each of them
|
||||||
|
private var generation = 0
|
||||||
|
|
||||||
|
init(mastodonController: MastodonController, context: FilterV1.Context) {
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
self.context = context
|
||||||
|
|
||||||
|
allFiltersObserver = mastodonController.$filters
|
||||||
|
.sink { [unowned self] in
|
||||||
|
if self.hasSetup {
|
||||||
|
self.setupFilters(filters: $0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupFilters(filters: [FilterMO]) {
|
||||||
|
let oldMatchers = matchers
|
||||||
|
|
||||||
|
matchers = []
|
||||||
|
filterObservers = []
|
||||||
|
for filter in filters where filter.contexts.contains(context) {
|
||||||
|
guard let matcher = filter.createMatcher() else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matchers.append(matcher)
|
||||||
|
|
||||||
|
filter.objectWillChange
|
||||||
|
.sink { [unowned self] _ in
|
||||||
|
// wait until after the change happens
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.setupFilters(filters: self.mastodonController.filters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &filterObservers)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasSetup {
|
||||||
|
var allMatch: Bool = false
|
||||||
|
var actionsChanged: Bool = false
|
||||||
|
if matchers.count != oldMatchers.count {
|
||||||
|
allMatch = false
|
||||||
|
actionsChanged = true
|
||||||
|
} else {
|
||||||
|
for (old, new) in zip(oldMatchers, matchers) {
|
||||||
|
if old.1 != new.1 {
|
||||||
|
allMatch = false
|
||||||
|
actionsChanged = true
|
||||||
|
break
|
||||||
|
} else if old.0.pattern != new.0.pattern {
|
||||||
|
allMatch = false
|
||||||
|
// continue because we want to know if any actions changed
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allMatch {
|
||||||
|
generation += 1
|
||||||
|
filtersChanged?(actionsChanged)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hasSetup = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a closure for the status in case the result is cached and we don't need to look it up
|
||||||
|
func resolve(state: FilterState, status: () -> StatusMO) -> Filterer.Result {
|
||||||
|
switch state.state {
|
||||||
|
case .known(_, generation: let knownGen) where knownGen < generation:
|
||||||
|
fallthrough
|
||||||
|
case .unknown:
|
||||||
|
let result = doResolve(status: status())
|
||||||
|
state.state = .known(result, generation: generation)
|
||||||
|
return result
|
||||||
|
case .known(let result, _):
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setResult(_ result: Result, for state: FilterState) {
|
||||||
|
state.state = .known(result, generation: generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func doResolve(status: StatusMO) -> Result {
|
||||||
|
if !hasSetup {
|
||||||
|
setupFilters(filters: mastodonController.filters)
|
||||||
|
}
|
||||||
|
if matchers.isEmpty {
|
||||||
|
return .allow
|
||||||
|
}
|
||||||
|
lazy var text = htmlConverter.convert(status.content).string
|
||||||
|
for (regex, result) in matchers {
|
||||||
|
if (!status.spoilerText.isEmpty && regex.numberOfMatches(in: status.spoilerText, range: NSRange(location: 0, length: status.spoilerText.utf16.count)) > 0)
|
||||||
|
|| regex.numberOfMatches(in: text, range: NSRange(location: 0, length: text.utf16.count)) > 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return .allow
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Result: Equatable {
|
||||||
|
case allow
|
||||||
|
case hide
|
||||||
|
case warn(String)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension FilterMO {
|
||||||
|
func createMatcher() -> (NSRegularExpression, Filterer.Result)? {
|
||||||
|
guard keywords.count > 0 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: it would be cool to use the Regex builder stuff for this, but it's iOS 16 only
|
||||||
|
var pattern = ""
|
||||||
|
|
||||||
|
var isFirst = true
|
||||||
|
for keyword in keywordMOs {
|
||||||
|
if isFirst {
|
||||||
|
isFirst = false
|
||||||
|
} else {
|
||||||
|
pattern += "|"
|
||||||
|
}
|
||||||
|
pattern += "("
|
||||||
|
if keyword.wholeWord {
|
||||||
|
pattern += "\\b"
|
||||||
|
}
|
||||||
|
pattern += NSRegularExpression.escapedPattern(for: keyword.keyword)
|
||||||
|
if keyword.wholeWord {
|
||||||
|
pattern += "\\b"
|
||||||
|
}
|
||||||
|
pattern += ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.useUnicodeWordBoundaries, .caseInsensitive]) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: Filterer.Result
|
||||||
|
switch filterAction {
|
||||||
|
case .hide:
|
||||||
|
result = .hide
|
||||||
|
case .warn:
|
||||||
|
result = .warn(titleOrKeyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (regex, result)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,128 @@
|
||||||
|
//
|
||||||
|
// HTMLConverter.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/3/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SwiftSoup
|
||||||
|
import WebURL
|
||||||
|
import WebURLFoundationExtras
|
||||||
|
|
||||||
|
struct HTMLConverter {
|
||||||
|
|
||||||
|
static let defaultFont = UIFont.systemFont(ofSize: 17)
|
||||||
|
static let defaultColor = UIColor.label
|
||||||
|
static let defaultParagraphStyle: NSParagraphStyle = {
|
||||||
|
let style = NSMutableParagraphStyle()
|
||||||
|
// 2 points is enough that it doesn't make things look weirdly spaced out, but leaves a slight gap when there are multiple lines of emojis
|
||||||
|
style.lineSpacing = 2
|
||||||
|
return style
|
||||||
|
}()
|
||||||
|
|
||||||
|
var font: UIFont = defaultFont
|
||||||
|
var color: UIColor = defaultColor
|
||||||
|
var paragraphStyle: NSParagraphStyle = defaultParagraphStyle
|
||||||
|
|
||||||
|
func convert(_ html: String) -> NSAttributedString {
|
||||||
|
let doc = try! SwiftSoup.parseBodyFragment(html)
|
||||||
|
let body = doc.body()!
|
||||||
|
|
||||||
|
let attributedText = attributedTextForHTMLNode(body)
|
||||||
|
let mutAttrString = NSMutableAttributedString(attributedString: attributedText)
|
||||||
|
mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines)
|
||||||
|
mutAttrString.collapseWhitespace()
|
||||||
|
|
||||||
|
mutAttrString.addAttribute(.paragraphStyle, value: paragraphStyle, range: mutAttrString.fullRange)
|
||||||
|
|
||||||
|
return mutAttrString
|
||||||
|
}
|
||||||
|
|
||||||
|
private func attributedTextForHTMLNode(_ node: Node, usePreformattedText: Bool = false) -> NSAttributedString {
|
||||||
|
switch node {
|
||||||
|
case let node as TextNode:
|
||||||
|
let text: String
|
||||||
|
if usePreformattedText {
|
||||||
|
text = node.getWholeText()
|
||||||
|
} else {
|
||||||
|
text = node.text()
|
||||||
|
}
|
||||||
|
return NSAttributedString(string: text, attributes: [.font: font, .foregroundColor: color])
|
||||||
|
case let node as Element:
|
||||||
|
let attributed = NSMutableAttributedString(string: "", attributes: [.font: font, .foregroundColor: color])
|
||||||
|
for child in node.getChildNodes() {
|
||||||
|
attributed.append(attributedTextForHTMLNode(child, usePreformattedText: usePreformattedText || node.tagName() == "pre"))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch node.tagName() {
|
||||||
|
case "br":
|
||||||
|
// need to specify defaultFont here b/c otherwise it uses the default 12pt Helvetica which
|
||||||
|
// screws up its determination of the line height making multiple lines of emojis squash together
|
||||||
|
attributed.append(NSAttributedString(string: "\n", attributes: [.font: font]))
|
||||||
|
case "a":
|
||||||
|
let href = try! node.attr("href")
|
||||||
|
if let webURL = WebURL(href),
|
||||||
|
let url = URL(webURL) {
|
||||||
|
attributed.addAttribute(.link, value: url, range: attributed.fullRange)
|
||||||
|
} else if let url = URL(string: href) {
|
||||||
|
attributed.addAttribute(.link, value: url, range: attributed.fullRange)
|
||||||
|
}
|
||||||
|
case "p":
|
||||||
|
attributed.append(NSAttributedString(string: "\n\n", attributes: [.font: font]))
|
||||||
|
case "em", "i":
|
||||||
|
let currentFont: UIFont
|
||||||
|
if attributed.length == 0 {
|
||||||
|
currentFont = font
|
||||||
|
} else {
|
||||||
|
currentFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? font
|
||||||
|
}
|
||||||
|
attributed.addAttribute(.font, value: currentFont.withTraits(.traitItalic)!, range: attributed.fullRange)
|
||||||
|
case "strong", "b":
|
||||||
|
let currentFont: UIFont
|
||||||
|
if attributed.length == 0 {
|
||||||
|
currentFont = font
|
||||||
|
} else {
|
||||||
|
currentFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? font
|
||||||
|
}
|
||||||
|
attributed.addAttribute(.font, value: currentFont.withTraits(.traitBold)!, range: attributed.fullRange)
|
||||||
|
case "del":
|
||||||
|
attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: attributed.fullRange)
|
||||||
|
case "code":
|
||||||
|
// TODO: this probably breaks with dynamic type
|
||||||
|
attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: font.pointSize, weight: .regular), range: attributed.fullRange)
|
||||||
|
case "pre":
|
||||||
|
attributed.append(NSAttributedString(string: "\n\n"))
|
||||||
|
attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: font.pointSize, weight: .regular), range: attributed.fullRange)
|
||||||
|
case "ol", "ul":
|
||||||
|
attributed.append(NSAttributedString(string: "\n\n"))
|
||||||
|
attributed.trimLeadingCharactersInSet(.whitespacesAndNewlines)
|
||||||
|
case "li":
|
||||||
|
let parentEl = node.parent()!
|
||||||
|
let parentTag = parentEl.tagName()
|
||||||
|
let bullet: NSAttributedString
|
||||||
|
if parentTag == "ol" {
|
||||||
|
let index = (try? node.elementSiblingIndex()) ?? 0
|
||||||
|
// we use the monospace digit font so that the periods of all the list items line up
|
||||||
|
// TODO: this probably breaks with dynamic type
|
||||||
|
bullet = NSAttributedString(string: "\(index + 1).\t", attributes: [.font: UIFont.monospacedDigitSystemFont(ofSize: font.pointSize, weight: .regular)])
|
||||||
|
} else if parentTag == "ul" {
|
||||||
|
bullet = NSAttributedString(string: "\u{2022}\t", attributes: [.font: font])
|
||||||
|
} else {
|
||||||
|
bullet = NSAttributedString()
|
||||||
|
}
|
||||||
|
attributed.insert(bullet, at: 0)
|
||||||
|
attributed.append(NSAttributedString(string: "\n", attributes: [.font: font]))
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributed
|
||||||
|
default:
|
||||||
|
fatalError("Unexpected node type \(type(of: node))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,69 @@
|
||||||
|
//
|
||||||
|
// EditedFilter.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/2/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
class EditedFilter: ObservableObject {
|
||||||
|
let id: String?
|
||||||
|
@Published var title: String?
|
||||||
|
@Published var contexts: [FilterV1.Context]
|
||||||
|
@Published var expiresIn: TimeInterval?
|
||||||
|
@Published var keywords: [Keyword]
|
||||||
|
@Published var action: FilterV2.Action
|
||||||
|
|
||||||
|
init() {
|
||||||
|
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
|
||||||
|
self.contexts = mo.contexts
|
||||||
|
if let expiresAt = mo.expiresAt {
|
||||||
|
expiresIn = expiresAt.timeIntervalSinceNow
|
||||||
|
}
|
||||||
|
self.keywords = mo.keywordMOs.map {
|
||||||
|
Keyword(id: $0.id, keyword: $0.keyword, wholeWord: $0.wholeWord)
|
||||||
|
}
|
||||||
|
self.action = mo.filterAction
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
var wholeWord: Bool
|
||||||
|
}
|
||||||
|
}
|
|
@ -42,9 +42,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
|
|
||||||
let controller = MastodonController.getForAccount(account)
|
let controller = MastodonController.getForAccount(account)
|
||||||
session.mastodonController = controller
|
session.mastodonController = controller
|
||||||
Task {
|
controller.initialize()
|
||||||
try? await controller.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let rootVC = viewController(for: activity, mastodonController: controller) else {
|
guard let rootVC = viewController(for: activity, mastodonController: controller) else {
|
||||||
UIApplication.shared.requestSceneSessionDestruction(session, options: nil, errorHandler: nil)
|
UIApplication.shared.requestSceneSessionDestruction(session, options: nil, errorHandler: nil)
|
||||||
|
|
|
@ -50,9 +50,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
session.mastodonController = controller
|
session.mastodonController = controller
|
||||||
Task {
|
controller.initialize()
|
||||||
try? await controller.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
let composeVC = ComposeHostingController(draft: draft, mastodonController: controller)
|
let composeVC = ComposeHostingController(draft: draft, mastodonController: controller)
|
||||||
composeVC.delegate = self
|
composeVC.delegate = self
|
||||||
|
|
|
@ -212,9 +212,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
|
|
||||||
func createAppUI() -> TuskerRootViewController {
|
func createAppUI() -> TuskerRootViewController {
|
||||||
let mastodonController = window!.windowScene!.session.mastodonController!
|
let mastodonController = window!.windowScene!.session.mastodonController!
|
||||||
Task {
|
mastodonController.initialize()
|
||||||
try? await mastodonController.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
let split = MainSplitViewController(mastodonController: mastodonController)
|
let split = MainSplitViewController(mastodonController: mastodonController)
|
||||||
if UIDevice.current.userInterfaceIdiom == .phone,
|
if UIDevice.current.userInterfaceIdiom == .phone,
|
||||||
|
|
|
@ -17,7 +17,7 @@ class BookmarksTableViewController: EnhancedTableViewController {
|
||||||
|
|
||||||
private var loaded = false
|
private var loaded = false
|
||||||
|
|
||||||
var statuses: [(id: String, state: StatusState)] = []
|
var statuses: [(id: String, state: CollapseState)] = []
|
||||||
|
|
||||||
var newer: RequestRange?
|
var newer: RequestRange?
|
||||||
var older: RequestRange?
|
var older: RequestRange?
|
||||||
|
|
|
@ -30,7 +30,7 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
weak var mastodonController: MastodonController!
|
weak var mastodonController: MastodonController!
|
||||||
|
|
||||||
let mainStatusID: String
|
let mainStatusID: String
|
||||||
let mainStatusState: StatusState
|
let mainStatusState: CollapseState
|
||||||
var statusIDToScrollToOnLoad: String
|
var statusIDToScrollToOnLoad: String
|
||||||
private(set) var dataSource: UITableViewDiffableDataSource<Section, Item>!
|
private(set) var dataSource: UITableViewDiffableDataSource<Section, Item>!
|
||||||
|
|
||||||
|
@ -39,7 +39,7 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
|
|
||||||
private var loadingState = LoadingState.unloaded
|
private var loadingState = LoadingState.unloaded
|
||||||
|
|
||||||
init(for mainStatusID: String, state: StatusState = .unknown, mastodonController: MastodonController) {
|
init(for mainStatusID: String, state: CollapseState = .unknown, mastodonController: MastodonController) {
|
||||||
self.mainStatusID = mainStatusID
|
self.mainStatusID = mainStatusID
|
||||||
self.mainStatusState = state
|
self.mainStatusState = state
|
||||||
self.statusIDToScrollToOnLoad = mainStatusID
|
self.statusIDToScrollToOnLoad = mainStatusID
|
||||||
|
@ -336,7 +336,7 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func item(for indexPath: IndexPath) -> (id: String, state: StatusState)? {
|
func item(for indexPath: IndexPath) -> (id: String, state: CollapseState)? {
|
||||||
return self.dataSource.itemIdentifier(for: indexPath).flatMap { (item) in
|
return self.dataSource.itemIdentifier(for: indexPath).flatMap { (item) in
|
||||||
switch item {
|
switch item {
|
||||||
case let .status(id: id, state: state):
|
case let .status(id: id, state: state):
|
||||||
|
@ -402,7 +402,7 @@ extension ConversationTableViewController {
|
||||||
case childThread(firstStatusID: String)
|
case childThread(firstStatusID: String)
|
||||||
}
|
}
|
||||||
enum Item: Hashable {
|
enum Item: Hashable {
|
||||||
case status(id: String, state: StatusState)
|
case status(id: String, state: CollapseState)
|
||||||
case expandThread(childThreads: [ConversationNode], inline: Bool)
|
case expandThread(childThreads: [ConversationNode], inline: Bool)
|
||||||
|
|
||||||
static func == (lhs: Item, rhs: Item) -> Bool {
|
static func == (lhs: Item, rhs: Item) -> Bool {
|
||||||
|
@ -443,7 +443,7 @@ extension ConversationTableViewController {
|
||||||
extension ConversationTableViewController: TuskerNavigationDelegate {
|
extension ConversationTableViewController: TuskerNavigationDelegate {
|
||||||
var apiController: MastodonController! { mastodonController }
|
var apiController: MastodonController! { mastodonController }
|
||||||
|
|
||||||
func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController {
|
func conversation(mainStatusID: String, state: CollapseState) -> ConversationTableViewController {
|
||||||
let vc = ConversationTableViewController(for: mainStatusID, state: state, mastodonController: mastodonController)
|
let vc = ConversationTableViewController(for: mainStatusID, state: state, mastodonController: mastodonController)
|
||||||
// transfer show statuses automatically state when showing new conversation
|
// transfer show statuses automatically state when showing new conversation
|
||||||
vc.showStatusesAutomatically = self.showStatusesAutomatically
|
vc.showStatusesAutomatically = self.showStatusesAutomatically
|
||||||
|
|
|
@ -62,9 +62,10 @@ class TrendingStatusesViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||||
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState)> { [unowned self] cell, indexPath, item in
|
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, CollapseState)> { [unowned self] cell, indexPath, item in
|
||||||
cell.delegate = self
|
cell.delegate = self
|
||||||
cell.updateUI(statusID: item.0, state: item.1)
|
// TODO: filter these
|
||||||
|
cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow)
|
||||||
}
|
}
|
||||||
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, _, _ in
|
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, _, _ in
|
||||||
cell.indicator.startAnimating()
|
cell.indicator.startAnimating()
|
||||||
|
@ -119,7 +120,7 @@ extension TrendingStatusesViewController {
|
||||||
case statuses
|
case statuses
|
||||||
}
|
}
|
||||||
enum Item: Hashable {
|
enum Item: Hashable {
|
||||||
case status(id: String, state: StatusState)
|
case status(id: String, state: CollapseState)
|
||||||
case loadingIndicator
|
case loadingIndicator
|
||||||
|
|
||||||
static func ==(lhs: Item, rhs: Item) -> Bool {
|
static func ==(lhs: Item, rhs: Item) -> Bool {
|
||||||
|
@ -206,6 +207,10 @@ extension TrendingStatusesViewController: StatusCollectionViewCellDelegate {
|
||||||
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
|
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func statusCellShowFiltered(_ cell: StatusCollectionViewCell) {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension TrendingStatusesViewController: StatusBarTappableViewController {
|
extension TrendingStatusesViewController: StatusBarTappableViewController {
|
||||||
|
|
|
@ -0,0 +1,214 @@
|
||||||
|
//
|
||||||
|
// EditFilterView.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/2/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
struct EditFilterView: View {
|
||||||
|
private static let expiresInOptions: [MenuPicker<TimeInterval>.Option] = {
|
||||||
|
let f = DateComponentsFormatter()
|
||||||
|
f.maximumUnitCount = 1
|
||||||
|
f.unitsStyle = .full
|
||||||
|
f.allowedUnits = [.weekOfMonth, .day, .hour, .minute]
|
||||||
|
|
||||||
|
let durations: [TimeInterval] = [
|
||||||
|
30 * 60,
|
||||||
|
60 * 60,
|
||||||
|
6 * 60 * 60,
|
||||||
|
24 * 60 * 60,
|
||||||
|
3 * 24 * 60 * 60,
|
||||||
|
7 * 24 * 60 * 60,
|
||||||
|
]
|
||||||
|
return durations.map { .init(value: $0, title: f.string(from: $0)!) }
|
||||||
|
}()
|
||||||
|
|
||||||
|
@ObservedObject var filter: EditedFilter
|
||||||
|
let create: Bool
|
||||||
|
@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, create: Bool) {
|
||||||
|
self.filter = filter
|
||||||
|
self.create = create
|
||||||
|
self._originalFilter = State(wrappedValue: EditedFilter(copying: filter))
|
||||||
|
if let expiresIn = filter.expiresIn {
|
||||||
|
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)
|
||||||
|
} else {
|
||||||
|
self._expiresIn = State(wrappedValue: 24 * 60 * 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@State private var expiresIn: TimeInterval {
|
||||||
|
didSet {
|
||||||
|
if expires.wrappedValue {
|
||||||
|
filter.expiresIn = expiresIn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private var expires: Binding<Bool> {
|
||||||
|
Binding {
|
||||||
|
filter.expiresIn != nil
|
||||||
|
} set: { newValue in
|
||||||
|
filter.expiresIn = newValue ? expiresIn : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
if mastodonController.instanceFeatures.filtersV2 {
|
||||||
|
Section {
|
||||||
|
TextField("Title", text: Binding(get: {
|
||||||
|
filter.title ?? ""
|
||||||
|
}, set: { newValue in
|
||||||
|
filter.title = newValue
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ForEach(Array($filter.keywords.enumerated()), id: \.offset) { keyword in
|
||||||
|
VStack {
|
||||||
|
TextField("Phrase", text: keyword.element.keyword)
|
||||||
|
Toggle("Whole Word", isOn: keyword.element.wholeWord)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete(perform: mastodonController.instanceFeatures.filtersV2 ? { indices in
|
||||||
|
filter.keywords.remove(atOffsets: indices)
|
||||||
|
} : nil)
|
||||||
|
|
||||||
|
if mastodonController.instanceFeatures.filtersV2 {
|
||||||
|
Button {
|
||||||
|
let new = EditedFilter.Keyword(id: nil, keyword: "", wholeWord: true)
|
||||||
|
withAnimation {
|
||||||
|
filter.keywords.append(new)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Add Keyword", systemImage: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
if mastodonController.instanceFeatures.filtersV2 {
|
||||||
|
Picker(selection: $filter.action) {
|
||||||
|
ForEach(FilterV2.Action.allCases, id: \.self) { action in
|
||||||
|
Text(action.displayName).tag(action)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Action")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Toggle("Expires", isOn: expires)
|
||||||
|
|
||||||
|
if expires.wrappedValue {
|
||||||
|
Picker(selection: $expiresIn) {
|
||||||
|
ForEach(Self.expiresInOptions, id: \.value) { option in
|
||||||
|
Text(option.title).tag(option.value)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Duration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ForEach(FilterV1.Context.allCases, id: \.rawValue) { context in
|
||||||
|
Toggle(isOn: Binding(get: {
|
||||||
|
filter.contexts.contains(context)
|
||||||
|
}, set: { newValue in
|
||||||
|
if newValue {
|
||||||
|
if !filter.contexts.contains(context) {
|
||||||
|
filter.contexts.append(context)
|
||||||
|
}
|
||||||
|
} else if filter.contexts.count > 1 {
|
||||||
|
filter.contexts.removeAll(where: { $0 == context })
|
||||||
|
}
|
||||||
|
})) {
|
||||||
|
Text(context.displayName)
|
||||||
|
}
|
||||||
|
.toggleStyle(FilterContextToggleStyle())
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Contexts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Edit Filter")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
if isSaving {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
} else {
|
||||||
|
Button(create ? "Create" : "Save") {
|
||||||
|
saveFilter()
|
||||||
|
}
|
||||||
|
.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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveFilter() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
isSaving = true
|
||||||
|
if create {
|
||||||
|
try await CreateFilterService(filter: filter, mastodonController: mastodonController).run()
|
||||||
|
} else {
|
||||||
|
try await UpdateFilterService(filter: filter, mastodonController: mastodonController).run()
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
isSaving = false
|
||||||
|
saveError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FilterContextToggleStyle: ToggleStyle {
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
Button {
|
||||||
|
configuration.isOn.toggle()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
configuration.label
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
if configuration.isOn {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//struct EditFilterView_Previews: PreviewProvider {
|
||||||
|
// static var previews: some View {
|
||||||
|
// EditFilterView()
|
||||||
|
// }
|
||||||
|
//}
|
|
@ -0,0 +1,63 @@
|
||||||
|
//
|
||||||
|
// FilterRow.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/30/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
struct FilterRow: View {
|
||||||
|
@ObservedObject var filter: FilterMO
|
||||||
|
@EnvironmentObject var mastodonController: MastodonController
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
Text(mastodonController.instanceFeatures.filtersV2 ? filter.title ?? "" : filter.keywordMOs.first?.keyword ?? "")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let expiresAt = filter.expiresAt {
|
||||||
|
if expiresAt <= Date() {
|
||||||
|
Text("Expired")
|
||||||
|
.font(.body.lowercaseSmallCaps())
|
||||||
|
.foregroundColor(.red)
|
||||||
|
} else {
|
||||||
|
Text(expiresAt.formatted(.relative(presentation: .numeric, unitsStyle: .narrow)))
|
||||||
|
.font(.body.lowercaseSmallCaps())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if !mastodonController.instanceFeatures.filtersV2 && filter.keywordMOs.first?.wholeWord == true {
|
||||||
|
Text("Whole word")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FilterRow_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
let filter = FilterMO()
|
||||||
|
filter.id = "1"
|
||||||
|
// filter.phrase = "test"
|
||||||
|
filter.expiresAt = Date().addingTimeInterval(60 * 60)
|
||||||
|
// filter.wholeWord = true
|
||||||
|
filter.contexts = [.home]
|
||||||
|
return FilterRow(filter: filter)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,129 @@
|
||||||
|
//
|
||||||
|
// FiltersView.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/30/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
struct FiltersView: View {
|
||||||
|
let mastodonController: MastodonController
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
FiltersList()
|
||||||
|
.environmentObject(mastodonController)
|
||||||
|
.environment(\.managedObjectContext, mastodonController.persistentContainer.viewContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FiltersList: View {
|
||||||
|
@EnvironmentObject private var mastodonController: MastodonController
|
||||||
|
@FetchRequest(sortDescriptors: []) private var filters: FetchedResults<FilterMO>
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var deletionError: (any Error)?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
NavigationStack {
|
||||||
|
navigationBody
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NavigationView {
|
||||||
|
navigationBody
|
||||||
|
}
|
||||||
|
.navigationViewStyle(.stack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var unexpiredFilters: [FilterMO] {
|
||||||
|
filters.filter { $0.expiresAt == nil || $0.expiresAt! > Date() }.sorted(using: SemiCaseSensitiveComparator.keyPath(\.titleOrKeyword))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var expiredFilters: [FilterMO] {
|
||||||
|
filters.filter { $0.expiresAt != nil && $0.expiresAt! <= Date() }.sorted(using: SemiCaseSensitiveComparator.keyPath(\.titleOrKeyword))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var navigationBody: some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
NavigationLink {
|
||||||
|
EditFilterView(filter: EditedFilter(), create: true)
|
||||||
|
} label: {
|
||||||
|
Label("Add Filter", systemImage: "plus")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filtersSection(unexpiredFilters, header: Text("Active"))
|
||||||
|
filtersSection(expiredFilters, header: Text("Expired"))
|
||||||
|
}
|
||||||
|
.navigationTitle(Text("Filters"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alertWithData("Error Deleting Filter", data: $deletionError, actions: { _ in
|
||||||
|
Button("OK") {
|
||||||
|
self.deletionError = nil
|
||||||
|
}
|
||||||
|
}, message: { error in
|
||||||
|
Text(error.localizedDescription)
|
||||||
|
})
|
||||||
|
.task {
|
||||||
|
await mastodonController.loadFilters()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func filtersSection(_ filters: [FilterMO], header: some View) -> some View {
|
||||||
|
if !filters.isEmpty {
|
||||||
|
Section {
|
||||||
|
ForEach(filters, id: \.id) { filter in
|
||||||
|
NavigationLink {
|
||||||
|
EditFilterView(filter: EditedFilter(filter), create: false)
|
||||||
|
} label: {
|
||||||
|
FilterRow(filter: filter)
|
||||||
|
}
|
||||||
|
.contextMenu {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
deleteFilter(filter)
|
||||||
|
} label: {
|
||||||
|
Label("Delete Filter", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete { indices in
|
||||||
|
for filter in indices.map({ filters[$0] }) {
|
||||||
|
deleteFilter(filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
header
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteFilter(_ filter: FilterMO) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await DeleteFilterService(filter: filter, mastodonController: mastodonController).run()
|
||||||
|
} catch {
|
||||||
|
self.deletionError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//struct FiltersView_Previews: PreviewProvider {
|
||||||
|
// static var previews: some View {
|
||||||
|
// FiltersView()
|
||||||
|
// }
|
||||||
|
//}
|
|
@ -22,7 +22,7 @@ struct MuteAccountView: View {
|
||||||
6 * 60 * 60,
|
6 * 60 * 60,
|
||||||
24 * 60 * 60,
|
24 * 60 * 60,
|
||||||
3 * 24 * 60 * 60,
|
3 * 24 * 60 * 60,
|
||||||
7 * 60 * 60 * 60,
|
7 * 24 * 60 * 60,
|
||||||
]
|
]
|
||||||
return [
|
return [
|
||||||
.init(value: 0, title: "Forever")
|
.init(value: 0, title: "Forever")
|
||||||
|
@ -114,7 +114,7 @@ struct MuteAccountView: View {
|
||||||
.disabled(isMuting)
|
.disabled(isMuting)
|
||||||
}
|
}
|
||||||
.alertWithData("Erorr Muting", data: $error, actions: { error in
|
.alertWithData("Erorr Muting", data: $error, actions: { error in
|
||||||
Button("Ok") {}
|
Button("OK") {}
|
||||||
}, message: { error in
|
}, message: { error in
|
||||||
Text(error.localizedDescription)
|
Text(error.localizedDescription)
|
||||||
})
|
})
|
||||||
|
|
|
@ -147,7 +147,7 @@ extension OnboardingViewController: InstanceSelectorTableViewControllerDelegate
|
||||||
// no-op, don't show an error message
|
// no-op, don't show an error message
|
||||||
} catch let error as Error {
|
} catch let error as Error {
|
||||||
let alert = UIAlertController(title: "Error Logging In", message: error.localizedDescription, preferredStyle: .alert)
|
let alert = UIAlertController(title: "Error Logging In", message: error.localizedDescription, preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction(title: "Ok", style: .default))
|
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||||
self.present(alert, animated: true)
|
self.present(alert, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
|
|
||||||
weak var owner: ProfileViewController?
|
weak var owner: ProfileViewController?
|
||||||
let mastodonController: MastodonController
|
let mastodonController: MastodonController
|
||||||
|
let filterer: Filterer
|
||||||
private(set) var accountID: String!
|
private(set) var accountID: String!
|
||||||
let kind: Kind
|
let kind: Kind
|
||||||
var initialHeaderMode: HeaderMode?
|
var initialHeaderMode: HeaderMode?
|
||||||
|
@ -38,6 +39,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
self.owner = owner
|
self.owner = owner
|
||||||
self.mastodonController = owner.mastodonController
|
self.mastodonController = owner.mastodonController
|
||||||
|
self.filterer = Filterer(mastodonController: mastodonController, context: .account)
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
@ -67,7 +69,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
config.topSeparatorVisibility = .hidden
|
config.topSeparatorVisibility = .hidden
|
||||||
config.bottomSeparatorVisibility = .hidden
|
config.bottomSeparatorVisibility = .hidden
|
||||||
}
|
}
|
||||||
if case .status(_, _, _) = item {
|
if case .status(_, _, _, _) = item {
|
||||||
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||||
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||||
}
|
}
|
||||||
|
@ -106,14 +108,18 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
filterer.filtersChanged = { [unowned self] actionsChanged in
|
||||||
|
self.reapplyFilters(actionsChanged: actionsChanged)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||||
collectionView.register(ProfileHeaderCollectionViewCell.self, forCellWithReuseIdentifier: "headerCell")
|
collectionView.register(ProfileHeaderCollectionViewCell.self, forCellWithReuseIdentifier: "headerCell")
|
||||||
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState, Bool)> { [unowned self] cell, indexPath, item in
|
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, CollapseState, Filterer.Result, Bool)> { [unowned self] cell, indexPath, item in
|
||||||
cell.delegate = self
|
cell.delegate = self
|
||||||
cell.showPinned = item.2
|
cell.showPinned = item.3
|
||||||
cell.updateUI(statusID: item.0, state: item.1)
|
cell.updateUI(statusID: item.0, state: item.1, filterResult: item.2)
|
||||||
}
|
}
|
||||||
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
|
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
|
||||||
switch itemIdentifier {
|
switch itemIdentifier {
|
||||||
|
@ -139,8 +145,9 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
self.headerCell = cell
|
self.headerCell = cell
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
case .status(id: let id, state: let state, pinned: let pinned):
|
case .status(id: let id, collapseState: let collapseState, filterState: let filterState, pinned: let pinned):
|
||||||
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, pinned))
|
let result = filterResult(state: filterState, statusID: id)
|
||||||
|
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, collapseState, result, pinned))
|
||||||
case .loadingIndicator:
|
case .loadingIndicator:
|
||||||
return loadingIndicatorCell(for: indexPath)
|
return loadingIndicatorCell(for: indexPath)
|
||||||
case .confirmLoadMore:
|
case .confirmLoadMore:
|
||||||
|
@ -225,7 +232,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
let existingPinned = snapshot.itemIdentifiers(inSection: .pinned)
|
let existingPinned = snapshot.itemIdentifiers(inSection: .pinned)
|
||||||
let items = statuses.map {
|
let items = statuses.map {
|
||||||
let item = Item.status(id: $0.id, state: .unknown, pinned: true)
|
let item = Item.status(id: $0.id, collapseState: .unknown, filterState: .unknown, pinned: true)
|
||||||
// try to keep the existing status state
|
// try to keep the existing status state
|
||||||
if let existing = existingPinned.first(where: { $0 == item }) {
|
if let existing = existingPinned.first(where: { $0 == item }) {
|
||||||
return existing
|
return existing
|
||||||
|
@ -238,6 +245,38 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
await apply(snapshot, animatingDifferences: true)
|
await apply(snapshot, animatingDifferences: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func filterResult(state: FilterState, statusID: String) -> Filterer.Result {
|
||||||
|
let status = {
|
||||||
|
let status = self.mastodonController.persistentContainer.status(for: statusID)!
|
||||||
|
// if the status is a reblog of another one, filter based on that one
|
||||||
|
return status.reblog ?? status
|
||||||
|
}
|
||||||
|
return filterer.resolve(state: state, status: status)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reapplyFilters(actionsChanged: Bool) {
|
||||||
|
let visible = collectionView.indexPathsForVisibleItems
|
||||||
|
let items = visible
|
||||||
|
.compactMap { dataSource.itemIdentifier(for: $0) }
|
||||||
|
.filter {
|
||||||
|
if case .status(_, _, _, _) = $0 {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard !items.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
if actionsChanged {
|
||||||
|
snapshot.reloadItems(items)
|
||||||
|
} else {
|
||||||
|
snapshot.reconfigureItems(items)
|
||||||
|
}
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
@objc func refresh() {
|
@objc func refresh() {
|
||||||
guard case .loaded = state else {
|
guard case .loaded = state else {
|
||||||
#if !targetEnvironment(macCatalyst)
|
#if !targetEnvironment(macCatalyst)
|
||||||
|
@ -288,19 +327,19 @@ extension ProfileStatusesViewController {
|
||||||
typealias TimelineItem = String
|
typealias TimelineItem = String
|
||||||
|
|
||||||
case header(String)
|
case header(String)
|
||||||
case status(id: String, state: StatusState, pinned: Bool)
|
case status(id: String, collapseState: CollapseState, filterState: FilterState, pinned: Bool)
|
||||||
case loadingIndicator
|
case loadingIndicator
|
||||||
case confirmLoadMore
|
case confirmLoadMore
|
||||||
|
|
||||||
static func fromTimelineItem(_ item: String) -> Self {
|
static func fromTimelineItem(_ item: String) -> Self {
|
||||||
return .status(id: item, state: .unknown, pinned: false)
|
return .status(id: item, collapseState: .unknown, filterState: .unknown, pinned: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func ==(lhs: Item, rhs: Item) -> Bool {
|
static func ==(lhs: Item, rhs: Item) -> Bool {
|
||||||
switch (lhs, rhs) {
|
switch (lhs, rhs) {
|
||||||
case let (.header(a), .header(b)):
|
case let (.header(a), .header(b)):
|
||||||
return a == b
|
return a == b
|
||||||
case let (.status(id: a, state: _, pinned: ap), .status(id: b, state: _, pinned: bp)):
|
case let (.status(id: a, _, _, pinned: ap), .status(id: b, _, _, pinned: bp)):
|
||||||
return a == b && ap == bp
|
return a == b && ap == bp
|
||||||
case (.loadingIndicator, .loadingIndicator):
|
case (.loadingIndicator, .loadingIndicator):
|
||||||
return true
|
return true
|
||||||
|
@ -316,7 +355,7 @@ extension ProfileStatusesViewController {
|
||||||
case .header(let id):
|
case .header(let id):
|
||||||
hasher.combine(0)
|
hasher.combine(0)
|
||||||
hasher.combine(id)
|
hasher.combine(id)
|
||||||
case .status(id: let id, state: _, pinned: let pinned):
|
case .status(id: let id, _, _, pinned: let pinned):
|
||||||
hasher.combine(1)
|
hasher.combine(1)
|
||||||
hasher.combine(id)
|
hasher.combine(id)
|
||||||
hasher.combine(pinned)
|
hasher.combine(pinned)
|
||||||
|
@ -338,7 +377,7 @@ extension ProfileStatusesViewController {
|
||||||
|
|
||||||
var isSelectable: Bool {
|
var isSelectable: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .status(id: _, state: _, pinned: _):
|
case .status(_, _, _, _):
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
@ -445,11 +484,20 @@ extension ProfileStatusesViewController: UICollectionViewDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
guard case .status(id: let id, state: let state, pinned: _) = dataSource.itemIdentifier(for: indexPath) else {
|
guard let item = dataSource.itemIdentifier(for: indexPath),
|
||||||
|
case .status(id: let id, collapseState: let collapseState, filterState: let filterState, pinned: _) = item else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if filterState.isWarning {
|
||||||
|
filterer.setResult(.allow, for: filterState)
|
||||||
|
collectionView.deselectItem(at: indexPath, animated: true)
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
snapshot.reconfigureItems([item])
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
} else {
|
||||||
let status = mastodonController.persistentContainer.status(for: id)!
|
let status = mastodonController.persistentContainer.status(for: id)!
|
||||||
selected(status: status.reblog?.id ?? id, state: state.copy())
|
selected(status: status.reblog?.id ?? id, state: collapseState.copy())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||||
|
@ -482,6 +530,17 @@ extension ProfileStatusesViewController: StatusCollectionViewCellDelegate {
|
||||||
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
|
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func statusCellShowFiltered(_ cell: StatusCollectionViewCell) {
|
||||||
|
if let indexPath = collectionView.indexPath(for: cell),
|
||||||
|
let item = dataSource.itemIdentifier(for: indexPath),
|
||||||
|
case .status(id: _, collapseState: _, filterState: let filterState, pinned: _) = item {
|
||||||
|
filterer.setResult(.allow, for: filterState)
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
snapshot.reconfigureItems([item])
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ProfileStatusesViewController: TabBarScrollableViewController {
|
extension ProfileStatusesViewController: TabBarScrollableViewController {
|
||||||
|
|
|
@ -248,7 +248,7 @@ extension SearchResultsViewController {
|
||||||
enum Item: Hashable {
|
enum Item: Hashable {
|
||||||
case account(String)
|
case account(String)
|
||||||
case hashtag(Hashtag)
|
case hashtag(Hashtag)
|
||||||
case status(String, StatusState)
|
case status(String, CollapseState)
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
func hash(into hasher: inout Hasher) {
|
||||||
switch self {
|
switch self {
|
||||||
|
|
|
@ -14,7 +14,7 @@ class StatusActionAccountListViewController: UIViewController {
|
||||||
private let mastodonController: MastodonController
|
private let mastodonController: MastodonController
|
||||||
private let actionType: ActionType
|
private let actionType: ActionType
|
||||||
private let statusID: String
|
private let statusID: String
|
||||||
private let statusState: StatusState
|
private let statusState: CollapseState
|
||||||
private var accountIDs: [String]?
|
private var accountIDs: [String]?
|
||||||
|
|
||||||
/// If `true`, a warning will be shown below the account list describing that the total favs/reblogs may be innacurate.
|
/// If `true`, a warning will be shown below the account list describing that the total favs/reblogs may be innacurate.
|
||||||
|
@ -33,7 +33,7 @@ class StatusActionAccountListViewController: UIViewController {
|
||||||
- Parameter accountIDs The accounts that will be shown. If `nil` is passed, a request will be performed to load the accounts.
|
- Parameter accountIDs The accounts that will be shown. If `nil` is passed, a request will be performed to load the accounts.
|
||||||
- Parameter mastodonController The `MastodonController` instance this view controller uses.
|
- Parameter mastodonController The `MastodonController` instance this view controller uses.
|
||||||
*/
|
*/
|
||||||
init(actionType: ActionType, statusID: String, statusState: StatusState, accountIDs: [String]?, mastodonController: MastodonController) {
|
init(actionType: ActionType, statusID: String, statusState: CollapseState, accountIDs: [String]?, mastodonController: MastodonController) {
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
self.actionType = actionType
|
self.actionType = actionType
|
||||||
self.statusID = statusID
|
self.statusID = statusID
|
||||||
|
@ -80,7 +80,7 @@ class StatusActionAccountListViewController: UIViewController {
|
||||||
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||||
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, Void> { [unowned self] cell, indexPath, _ in
|
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, Void> { [unowned self] cell, indexPath, _ in
|
||||||
cell.delegate = self
|
cell.delegate = self
|
||||||
cell.updateUI(statusID: self.statusID, state: self.statusState)
|
cell.updateUI(statusID: self.statusID, state: self.statusState, filterResult: .allow)
|
||||||
}
|
}
|
||||||
let accountCell = UICollectionView.CellRegistration<AccountCollectionViewCell, String> { [unowned self] cell, indexPath, item in
|
let accountCell = UICollectionView.CellRegistration<AccountCollectionViewCell, String> { [unowned self] cell, indexPath, item in
|
||||||
cell.delegate = self
|
cell.delegate = self
|
||||||
|
@ -260,6 +260,10 @@ extension StatusActionAccountListViewController: StatusCollectionViewCellDelegat
|
||||||
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
|
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func statusCellShowFiltered(_ cell: StatusCollectionViewCell) {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension StatusActionAccountListViewController: StatusBarTappableViewController {
|
extension StatusActionAccountListViewController: StatusBarTappableViewController {
|
||||||
|
|
|
@ -69,10 +69,10 @@ class InstanceTimelineViewController: TimelineViewController {
|
||||||
toggleSaveButton.title = toggleSaveButtonTitle
|
toggleSaveButton.title = toggleSaveButtonTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
override func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: StatusState) {
|
override func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result) {
|
||||||
cell.delegate = browsingEnabled ? self : nil
|
cell.delegate = browsingEnabled ? self : nil
|
||||||
cell.overrideMastodonController = mastodonController
|
cell.overrideMastodonController = mastodonController
|
||||||
cell.updateUI(statusID: id, state: state)
|
cell.updateUI(statusID: id, state: state, filterResult: filterResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
|
|
@ -13,6 +13,7 @@ import Combine
|
||||||
class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController, RefreshableViewController {
|
class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController, RefreshableViewController {
|
||||||
let timeline: Timeline
|
let timeline: Timeline
|
||||||
weak var mastodonController: MastodonController!
|
weak var mastodonController: MastodonController!
|
||||||
|
let filterer: Filterer
|
||||||
|
|
||||||
private(set) var controller: TimelineLikeController<TimelineItem>!
|
private(set) var controller: TimelineLikeController<TimelineItem>!
|
||||||
let confirmLoadMore = PassthroughSubject<Void, Never>()
|
let confirmLoadMore = PassthroughSubject<Void, Never>()
|
||||||
|
@ -28,6 +29,14 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
init(for timeline: Timeline, mastodonController: MastodonController!) {
|
init(for timeline: Timeline, mastodonController: MastodonController!) {
|
||||||
self.timeline = timeline
|
self.timeline = timeline
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
|
let filterContext: FilterV1.Context
|
||||||
|
switch timeline {
|
||||||
|
case .home, .list(id: _):
|
||||||
|
filterContext = .home
|
||||||
|
default:
|
||||||
|
filterContext = .public
|
||||||
|
}
|
||||||
|
self.filterer = Filterer(mastodonController: mastodonController, context: filterContext)
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
@ -59,6 +68,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
if item.hideSeparators {
|
if item.hideSeparators {
|
||||||
config.topSeparatorVisibility = .hidden
|
config.topSeparatorVisibility = .hidden
|
||||||
config.bottomSeparatorVisibility = .hidden
|
config.bottomSeparatorVisibility = .hidden
|
||||||
|
} else if case .status(id: let id, collapseState: _, filterState: let filterState) = item,
|
||||||
|
case .hide = filterResult(state: filterState, statusID: id) {
|
||||||
|
config.topSeparatorVisibility = .hidden
|
||||||
|
config.bottomSeparatorVisibility = .hidden
|
||||||
} else {
|
} else {
|
||||||
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||||
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||||
|
@ -95,23 +108,29 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
filterer.filtersChanged = { [unowned self] actionsChanged in
|
||||||
|
self.reapplyFilters(actionsChanged: actionsChanged)
|
||||||
|
}
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(sceneWillEnterForeground), name: UIScene.willEnterForegroundNotification, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(sceneWillEnterForeground), name: UIScene.willEnterForegroundNotification, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// separate method because InstanceTimelineViewController needs to be able to customize it
|
// separate method because InstanceTimelineViewController needs to be able to customize it
|
||||||
func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: StatusState) {
|
func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result) {
|
||||||
cell.delegate = self
|
cell.delegate = self
|
||||||
if case .home = timeline {
|
if case .home = timeline {
|
||||||
cell.showFollowedHashtags = true
|
cell.showFollowedHashtags = true
|
||||||
} else {
|
} else {
|
||||||
cell.showFollowedHashtags = false
|
cell.showFollowedHashtags = false
|
||||||
}
|
}
|
||||||
cell.updateUI(statusID: id, state: state)
|
cell.updateUI(statusID: id, state: state, filterResult: filterResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||||
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState)> { [unowned self] cell, indexPath, item in
|
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, CollapseState, Filterer.Result)> { [unowned self] cell, indexPath, item in
|
||||||
self.configureStatusCell(cell, id: item.0, state: item.1)
|
self.configureStatusCell(cell, id: item.0, state: item.1, filterResult: item.2)
|
||||||
|
}
|
||||||
|
let zeroHeightCell = UICollectionView.CellRegistration<ZeroHeightCollectionViewCell, Void> { _, _, _ in
|
||||||
}
|
}
|
||||||
let gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { cell, indexPath, _ in
|
let gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { cell, indexPath, _ in
|
||||||
cell.showsIndicator = false
|
cell.showsIndicator = false
|
||||||
|
@ -128,8 +147,14 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
}
|
}
|
||||||
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
|
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
|
||||||
switch itemIdentifier {
|
switch itemIdentifier {
|
||||||
case .status(id: let id, state: let state):
|
case .status(id: let id, collapseState: let state, filterState: let filterState):
|
||||||
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state))
|
let result = filterResult(state: filterState, statusID: id)
|
||||||
|
switch result {
|
||||||
|
case .allow, .warn(_):
|
||||||
|
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, result))
|
||||||
|
case .hide:
|
||||||
|
return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ())
|
||||||
|
}
|
||||||
case .gap:
|
case .gap:
|
||||||
return collectionView.dequeueConfiguredReusableCell(using: gapCell, for: indexPath, item: ())
|
return collectionView.dequeueConfiguredReusableCell(using: gapCell, for: indexPath, item: ())
|
||||||
case .loadingIndicator:
|
case .loadingIndicator:
|
||||||
|
@ -229,14 +254,14 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
centerVisibleItem = allItems[centerVisible.row]
|
centerVisibleItem = allItems[centerVisible.row]
|
||||||
}
|
}
|
||||||
let ids = items.map {
|
let ids = items.map {
|
||||||
if case .status(id: let id, state: _) = $0 {
|
if case .status(id: let id, _, _) = $0 {
|
||||||
return id
|
return id
|
||||||
} else {
|
} else {
|
||||||
fatalError()
|
fatalError()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let centerVisibleID: String
|
let centerVisibleID: String
|
||||||
if case .status(id: let id, state: _) = centerVisibleItem {
|
if case .status(id: let id, _, _) = centerVisibleItem {
|
||||||
centerVisibleID = id
|
centerVisibleID = id
|
||||||
} else {
|
} else {
|
||||||
fatalError()
|
fatalError()
|
||||||
|
@ -269,7 +294,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
controller.restoreInitial {
|
controller.restoreInitial {
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
snapshot.appendSections([.statuses])
|
snapshot.appendSections([.statuses])
|
||||||
let items = statusIDs.map { Item.status(id: $0, state: .unknown) }
|
let items = statusIDs.map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
|
||||||
snapshot.appendItems(items, toSection: .statuses)
|
snapshot.appendItems(items, toSection: .statuses)
|
||||||
dataSource.apply(snapshot, animatingDifferences: false) {
|
dataSource.apply(snapshot, animatingDifferences: false) {
|
||||||
if let centerID = activity.userInfo?["centerID"] as? String ?? activity.userInfo?["topID"] as? String,
|
if let centerID = activity.userInfo?["centerID"] as? String ?? activity.userInfo?["topID"] as? String,
|
||||||
|
@ -304,6 +329,40 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
isShowingTimelineDescription = false
|
isShowingTimelineDescription = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func filterResult(state: FilterState, statusID: String) -> Filterer.Result {
|
||||||
|
let status = {
|
||||||
|
let status = self.mastodonController.persistentContainer.status(for: statusID)!
|
||||||
|
// if the status is a reblog of another one, filter based on that one
|
||||||
|
return status.reblog ?? status
|
||||||
|
}
|
||||||
|
return filterer.resolve(state: state, status: status)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reapplyFilters(actionsChanged: Bool) {
|
||||||
|
let visible = collectionView.indexPathsForVisibleItems
|
||||||
|
let items = visible
|
||||||
|
.compactMap { dataSource.itemIdentifier(for: $0) }
|
||||||
|
.filter {
|
||||||
|
if case .status(_, _, _) = $0 {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard !items.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
if actionsChanged {
|
||||||
|
// need to reload not just reconfigure because hidden posts use a separate cell type
|
||||||
|
snapshot.reloadItems(items)
|
||||||
|
} else {
|
||||||
|
// reconfigure when possible to avoid the content offset jumping around
|
||||||
|
snapshot.reconfigureItems(items)
|
||||||
|
}
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func sceneWillEnterForeground(_ notification: Foundation.Notification) {
|
@objc private func sceneWillEnterForeground(_ notification: Foundation.Notification) {
|
||||||
guard let scene = notification.object as? UIScene,
|
guard let scene = notification.object as? UIScene,
|
||||||
// view.window is nil when the VC is not on screen
|
// view.window is nil when the VC is not on screen
|
||||||
|
@ -353,14 +412,14 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let currentItems = snapshot.itemIdentifiers(inSection: .statuses)
|
let currentItems = snapshot.itemIdentifiers(inSection: .statuses)
|
||||||
if case .status(id: let firstID, state: _) = currentItems.first,
|
if case .status(id: let firstID, _, _) = currentItems.first,
|
||||||
// if there's no overlap between presentItems and the existing items in the data source, prompt the user
|
// if there's no overlap between presentItems and the existing items in the data source, prompt the user
|
||||||
!presentItems.contains(firstID) {
|
!presentItems.contains(firstID) {
|
||||||
|
|
||||||
// create a new snapshot to reset the timeline to the "present" state
|
// create a new snapshot to reset the timeline to the "present" state
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
snapshot.appendSections([.statuses])
|
snapshot.appendSections([.statuses])
|
||||||
snapshot.appendItems(presentItems.map { .status(id: $0, state: .unknown) }, toSection: .statuses)
|
snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses)
|
||||||
|
|
||||||
var config = ToastConfiguration(title: "Jump to present")
|
var config = ToastConfiguration(title: "Jump to present")
|
||||||
config.edge = .top
|
config.edge = .top
|
||||||
|
@ -463,19 +522,19 @@ extension TimelineViewController {
|
||||||
enum Item: TimelineLikeCollectionViewItem {
|
enum Item: TimelineLikeCollectionViewItem {
|
||||||
typealias TimelineItem = String // status ID
|
typealias TimelineItem = String // status ID
|
||||||
|
|
||||||
case status(id: String, state: StatusState)
|
case status(id: String, collapseState: CollapseState, filterState: FilterState)
|
||||||
case gap
|
case gap
|
||||||
case loadingIndicator
|
case loadingIndicator
|
||||||
case confirmLoadMore
|
case confirmLoadMore
|
||||||
case publicTimelineDescription
|
case publicTimelineDescription
|
||||||
|
|
||||||
static func fromTimelineItem(_ id: String) -> Self {
|
static func fromTimelineItem(_ id: String) -> Self {
|
||||||
return .status(id: id, state: .unknown)
|
return .status(id: id, collapseState: .unknown, filterState: .unknown)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func ==(lhs: Item, rhs: Item) -> Bool {
|
static func ==(lhs: Item, rhs: Item) -> Bool {
|
||||||
switch (lhs, rhs) {
|
switch (lhs, rhs) {
|
||||||
case let (.status(id: a, state: _), .status(id: b, state: _)):
|
case let (.status(id: a, _, _), .status(id: b, _, _)):
|
||||||
return a == b
|
return a == b
|
||||||
case (.gap, .gap):
|
case (.gap, .gap):
|
||||||
return true
|
return true
|
||||||
|
@ -492,7 +551,7 @@ extension TimelineViewController {
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
func hash(into hasher: inout Hasher) {
|
||||||
switch self {
|
switch self {
|
||||||
case .status(id: let id, state: _):
|
case .status(id: let id, _, _):
|
||||||
hasher.combine(0)
|
hasher.combine(0)
|
||||||
hasher.combine(id)
|
hasher.combine(id)
|
||||||
case .gap:
|
case .gap:
|
||||||
|
@ -517,7 +576,7 @@ extension TimelineViewController {
|
||||||
|
|
||||||
var isSelectable: Bool {
|
var isSelectable: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .publicTimelineDescription, .gap, .status(id: _, state: _):
|
case .publicTimelineDescription, .gap, .status(_, _, _):
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
@ -547,7 +606,7 @@ extension TimelineViewController {
|
||||||
|
|
||||||
func loadNewer() async throws -> [TimelineItem] {
|
func loadNewer() async throws -> [TimelineItem] {
|
||||||
let statusesSection = dataSource.snapshot().indexOfSection(.statuses)!
|
let statusesSection = dataSource.snapshot().indexOfSection(.statuses)!
|
||||||
guard case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: 0, section: statusesSection)) else {
|
guard case .status(id: let id, _, _) = dataSource.itemIdentifier(for: IndexPath(row: 0, section: statusesSection)) else {
|
||||||
throw Error.noNewer
|
throw Error.noNewer
|
||||||
}
|
}
|
||||||
let newer = RequestRange.after(id: id, count: nil)
|
let newer = RequestRange.after(id: id, count: nil)
|
||||||
|
@ -571,7 +630,7 @@ extension TimelineViewController {
|
||||||
func loadOlder() async throws -> [TimelineItem] {
|
func loadOlder() async throws -> [TimelineItem] {
|
||||||
let snapshot = dataSource.snapshot()
|
let snapshot = dataSource.snapshot()
|
||||||
let statusesSection = snapshot.indexOfSection(.statuses)!
|
let statusesSection = snapshot.indexOfSection(.statuses)!
|
||||||
guard case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: snapshot.numberOfItems(inSection: .statuses) - 1, section: statusesSection)) else {
|
guard case .status(id: let id, _, _) = dataSource.itemIdentifier(for: IndexPath(row: snapshot.numberOfItems(inSection: .statuses) - 1, section: statusesSection)) else {
|
||||||
throw Error.noNewer
|
throw Error.noNewer
|
||||||
}
|
}
|
||||||
let older = RequestRange.before(id: id, count: nil)
|
let older = RequestRange.before(id: id, count: nil)
|
||||||
|
@ -601,14 +660,14 @@ extension TimelineViewController {
|
||||||
switch direction {
|
switch direction {
|
||||||
case .above:
|
case .above:
|
||||||
guard gapIndexPath.row > 0,
|
guard gapIndexPath.row > 0,
|
||||||
case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row - 1, section: gapIndexPath.section)) else {
|
case .status(id: let id, _, _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row - 1, section: gapIndexPath.section)) else {
|
||||||
// not really the right error but w/e
|
// not really the right error but w/e
|
||||||
throw Error.noGap
|
throw Error.noGap
|
||||||
}
|
}
|
||||||
range = .before(id: id, count: nil)
|
range = .before(id: id, count: nil)
|
||||||
case .below:
|
case .below:
|
||||||
guard gapIndexPath.row < statusItemsCount - 1,
|
guard gapIndexPath.row < statusItemsCount - 1,
|
||||||
case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row + 1, section: gapIndexPath.section)) else {
|
case .status(id: let id, _, _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row + 1, section: gapIndexPath.section)) else {
|
||||||
throw Error.noGap
|
throw Error.noGap
|
||||||
}
|
}
|
||||||
range = .after(id: id, count: nil)
|
range = .after(id: id, count: nil)
|
||||||
|
@ -658,13 +717,13 @@ extension TimelineViewController {
|
||||||
|
|
||||||
// if there is any overlap, the first overlapping item will be the first item below the gap
|
// if there is any overlap, the first overlapping item will be the first item below the gap
|
||||||
var indexOfFirstTimelineItemExistingBelowGap: Int?
|
var indexOfFirstTimelineItemExistingBelowGap: Int?
|
||||||
if case .status(id: let id, state: _) = afterGap.first {
|
if case .status(id: let id, _, _) = afterGap.first {
|
||||||
indexOfFirstTimelineItemExistingBelowGap = timelineItems.firstIndex(of: id)
|
indexOfFirstTimelineItemExistingBelowGap = timelineItems.firstIndex(of: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// the end index of the range of timelineItems that don't yet exist in the data source
|
// the end index of the range of timelineItems that don't yet exist in the data source
|
||||||
let endIndex = indexOfFirstTimelineItemExistingBelowGap ?? timelineItems.endIndex
|
let endIndex = indexOfFirstTimelineItemExistingBelowGap ?? timelineItems.endIndex
|
||||||
let toInsert = timelineItems[..<endIndex].map { Item.status(id: $0, state: .unknown) }
|
let toInsert = timelineItems[..<endIndex].map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
|
||||||
if toInsert.isEmpty {
|
if toInsert.isEmpty {
|
||||||
addedItems = false
|
addedItems = false
|
||||||
} else {
|
} else {
|
||||||
|
@ -686,7 +745,7 @@ extension TimelineViewController {
|
||||||
|
|
||||||
// if there's any overlap, last overlapping item will be the last item below the gap
|
// if there's any overlap, last overlapping item will be the last item below the gap
|
||||||
var indexOfLastTimelineItemExistingAboveGap: Int?
|
var indexOfLastTimelineItemExistingAboveGap: Int?
|
||||||
if case .status(id: let id, state: _) = beforeGap.last {
|
if case .status(id: let id, _, _) = beforeGap.last {
|
||||||
indexOfLastTimelineItemExistingAboveGap = timelineItems.lastIndex(of: id)
|
indexOfLastTimelineItemExistingAboveGap = timelineItems.lastIndex(of: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -698,7 +757,7 @@ extension TimelineViewController {
|
||||||
} else {
|
} else {
|
||||||
startIndex = timelineItems.startIndex
|
startIndex = timelineItems.startIndex
|
||||||
}
|
}
|
||||||
let toInsert = timelineItems[startIndex...].map { Item.status(id: $0, state: .unknown) }
|
let toInsert = timelineItems[startIndex...].map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
|
||||||
if toInsert.isEmpty {
|
if toInsert.isEmpty {
|
||||||
addedItems = false
|
addedItems = false
|
||||||
} else {
|
} else {
|
||||||
|
@ -762,10 +821,18 @@ extension TimelineViewController: UICollectionViewDelegate {
|
||||||
switch item {
|
switch item {
|
||||||
case .publicTimelineDescription:
|
case .publicTimelineDescription:
|
||||||
removeTimelineDescriptionCell()
|
removeTimelineDescriptionCell()
|
||||||
case .status(id: let id, state: let state):
|
case .status(id: let id, collapseState: let collapseState, filterState: let filterState):
|
||||||
|
if filterState.isWarning {
|
||||||
|
filterer.setResult(.allow, for: filterState)
|
||||||
|
collectionView.deselectItem(at: indexPath, animated: true)
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
snapshot.reconfigureItems([item])
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
} else {
|
||||||
let status = mastodonController.persistentContainer.status(for: id)!
|
let status = mastodonController.persistentContainer.status(for: id)!
|
||||||
// if the status in the timeline is a reblog, show the status that it is a reblog of
|
// if the status in the timeline is a reblog, show the status that it is a reblog of
|
||||||
selected(status: status.reblog?.id ?? id, state: state.copy())
|
selected(status: status.reblog?.id ?? id, state: collapseState.copy())
|
||||||
|
}
|
||||||
case .gap:
|
case .gap:
|
||||||
let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell
|
let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell
|
||||||
cell.showsIndicator = true
|
cell.showsIndicator = true
|
||||||
|
@ -814,6 +881,17 @@ extension TimelineViewController: StatusCollectionViewCellDelegate {
|
||||||
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
|
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func statusCellShowFiltered(_ cell: StatusCollectionViewCell) {
|
||||||
|
if let indexPath = collectionView.indexPath(for: cell),
|
||||||
|
let item = dataSource.itemIdentifier(for: indexPath),
|
||||||
|
case .status(id: _, collapseState: _, filterState: let filterState) = item {
|
||||||
|
filterer.setResult(.allow, for: filterState)
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
snapshot.reconfigureItems([item])
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension TimelineViewController: TabBarScrollableViewController {
|
extension TimelineViewController: TabBarScrollableViewController {
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
class TimelinesPageViewController: SegmentedPageViewController {
|
class TimelinesPageViewController: SegmentedPageViewController {
|
||||||
|
|
||||||
|
@ -40,6 +41,10 @@ class TimelinesPageViewController: SegmentedPageViewController {
|
||||||
|
|
||||||
title = homeTitle
|
title = homeTitle
|
||||||
tabBarItem.image = UIImage(systemName: "house.fill")
|
tabBarItem.image = UIImage(systemName: "house.fill")
|
||||||
|
|
||||||
|
let filtersItem = UIBarButtonItem(image: UIImage(systemName: "line.3.horizontal.decrease.circle"), style: .plain, target: self, action: #selector(filtersPressed))
|
||||||
|
filtersItem.accessibilityLabel = "Filters"
|
||||||
|
navigationItem.leftBarButtonItem = filtersItem
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
|
@ -70,4 +75,8 @@ class TimelinesPageViewController: SegmentedPageViewController {
|
||||||
timelineVC.restoreActivity(activity)
|
timelineVC.restoreActivity(activity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func filtersPressed() {
|
||||||
|
present(UIHostingController(rootView: FiltersView(mastodonController: mastodonController)), animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
//
|
||||||
|
// SemiCaseSensitiveComparator.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/30/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A comparator that sorts objects with a string key path case insensitively unless they're the same, in which case uppercase comes after lowercase.
|
||||||
|
struct SemiCaseSensitiveComparator: SortComparator {
|
||||||
|
var order: SortOrder = .forward
|
||||||
|
|
||||||
|
typealias Compared = String
|
||||||
|
|
||||||
|
static func keyPath<Object>(_ keyPath: KeyPath<Object, String>) -> KeyPathComparator<Object> {
|
||||||
|
return KeyPathComparator(keyPath, comparator: SemiCaseSensitiveComparator())
|
||||||
|
}
|
||||||
|
|
||||||
|
func compare(_ lhs: String, _ rhs: String) -> ComparisonResult {
|
||||||
|
let result = doCompare(lhs, rhs)
|
||||||
|
if case .reverse = order {
|
||||||
|
switch result {
|
||||||
|
case .orderedDescending:
|
||||||
|
return .orderedAscending
|
||||||
|
case .orderedAscending:
|
||||||
|
return .orderedDescending
|
||||||
|
case .orderedSame:
|
||||||
|
return .orderedSame
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func doCompare(_ lhs: String, _ rhs: String) -> ComparisonResult {
|
||||||
|
for (l, r) in zip(lhs, rhs) {
|
||||||
|
let lLower = l.lowercased()
|
||||||
|
let rLower = r.lowercased()
|
||||||
|
if lLower < rLower {
|
||||||
|
return .orderedAscending
|
||||||
|
} else if lLower > rLower {
|
||||||
|
return .orderedDescending
|
||||||
|
} else {
|
||||||
|
if l < r {
|
||||||
|
return .orderedDescending
|
||||||
|
} else if l > r {
|
||||||
|
return .orderedAscending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lhs.count > rhs.count {
|
||||||
|
return .orderedDescending
|
||||||
|
} else if lhs.count < rhs.count {
|
||||||
|
return .orderedAscending
|
||||||
|
} else {
|
||||||
|
return .orderedSame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,7 +13,7 @@ import Pachyderm
|
||||||
protocol TuskerNavigationDelegate: UIViewController, ToastableViewController {
|
protocol TuskerNavigationDelegate: UIViewController, ToastableViewController {
|
||||||
var apiController: MastodonController! { get }
|
var apiController: MastodonController! { get }
|
||||||
|
|
||||||
func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController
|
func conversation(mainStatusID: String, state: CollapseState) -> ConversationTableViewController
|
||||||
}
|
}
|
||||||
|
|
||||||
extension TuskerNavigationDelegate {
|
extension TuskerNavigationDelegate {
|
||||||
|
@ -59,7 +59,7 @@ extension TuskerNavigationDelegate {
|
||||||
message += " This can happen if you do not have an app installed for '\(scheme)://' URLs."
|
message += " This can happen if you do not have an app installed for '\(scheme)://' URLs."
|
||||||
}
|
}
|
||||||
let alert = UIAlertController(title: "Invalid URL", message: message, preferredStyle: .alert)
|
let alert = UIAlertController(title: "Invalid URL", message: message, preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil))
|
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
|
||||||
present(alert, animated: true)
|
present(alert, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -76,7 +76,7 @@ extension TuskerNavigationDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController {
|
func conversation(mainStatusID: String, state: CollapseState) -> ConversationTableViewController {
|
||||||
return ConversationTableViewController(for: mainStatusID, state: state, mastodonController: apiController)
|
return ConversationTableViewController(for: mainStatusID, state: state, mastodonController: apiController)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,7 +84,7 @@ extension TuskerNavigationDelegate {
|
||||||
self.selected(status: statusID, state: .unknown)
|
self.selected(status: statusID, state: .unknown)
|
||||||
}
|
}
|
||||||
|
|
||||||
func selected(status statusID: String, state: StatusState) {
|
func selected(status statusID: String, state: CollapseState) {
|
||||||
show(conversation(mainStatusID: statusID, state: state), sender: self)
|
show(conversation(mainStatusID: statusID, state: state), sender: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -183,7 +183,7 @@ extension TuskerNavigationDelegate {
|
||||||
show(vc, sender: self)
|
show(vc, sender: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusActionAccountList(action: StatusActionAccountListViewController.ActionType, statusID: String, statusState state: StatusState, accountIDs: [String]?) -> StatusActionAccountListViewController {
|
func statusActionAccountList(action: StatusActionAccountListViewController.ActionType, statusID: String, statusState state: CollapseState, accountIDs: [String]?) -> StatusActionAccountListViewController {
|
||||||
return StatusActionAccountListViewController(actionType: action, statusID: statusID, statusState: state, accountIDs: accountIDs, mastodonController: apiController)
|
return StatusActionAccountListViewController(actionType: action, statusID: statusID, statusState: state, accountIDs: accountIDs, mastodonController: apiController)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -22,14 +22,19 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||||
weak var overrideMastodonController: MastodonController?
|
weak var overrideMastodonController: MastodonController?
|
||||||
var mastodonController: MastodonController? { overrideMastodonController ?? navigationDelegate?.apiController }
|
var mastodonController: MastodonController? { overrideMastodonController ?? navigationDelegate?.apiController }
|
||||||
|
|
||||||
var defaultFont: UIFont = .systemFont(ofSize: 17)
|
private var htmlConverter = HTMLConverter()
|
||||||
var defaultColor: UIColor = .label
|
var defaultFont: UIFont {
|
||||||
var paragraphStyle: NSParagraphStyle = {
|
_read { yield htmlConverter.font }
|
||||||
let style = NSMutableParagraphStyle()
|
_modify { yield &htmlConverter.font }
|
||||||
// 2 points is enough that it doesn't make things look weirdly spaced out, but leaves a slight gap when there are multiple lines of emojis
|
}
|
||||||
style.lineSpacing = 2
|
var defaultColor: UIColor {
|
||||||
return style
|
_read { yield htmlConverter.color }
|
||||||
}()
|
_modify { yield &htmlConverter.color }
|
||||||
|
}
|
||||||
|
var paragraphStyle: NSParagraphStyle {
|
||||||
|
_read { yield htmlConverter.paragraphStyle }
|
||||||
|
_modify { yield &htmlConverter.paragraphStyle }
|
||||||
|
}
|
||||||
|
|
||||||
private(set) var hasEmojis = false
|
private(set) var hasEmojis = false
|
||||||
|
|
||||||
|
@ -85,99 +90,7 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||||
|
|
||||||
// MARK: - HTML Parsing
|
// MARK: - HTML Parsing
|
||||||
func setTextFromHtml(_ html: String) {
|
func setTextFromHtml(_ html: String) {
|
||||||
let doc = try! SwiftSoup.parse(html)
|
self.attributedText = htmlConverter.convert(html)
|
||||||
let body = doc.body()!
|
|
||||||
|
|
||||||
let attributedText = attributedTextForHTMLNode(body)
|
|
||||||
let mutAttrString = NSMutableAttributedString(attributedString: attributedText)
|
|
||||||
mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines)
|
|
||||||
mutAttrString.collapseWhitespace()
|
|
||||||
|
|
||||||
mutAttrString.addAttribute(.paragraphStyle, value: paragraphStyle, range: mutAttrString.fullRange)
|
|
||||||
|
|
||||||
self.attributedText = mutAttrString
|
|
||||||
}
|
|
||||||
|
|
||||||
private func attributedTextForHTMLNode(_ node: Node, usePreformattedText: Bool = false) -> NSAttributedString {
|
|
||||||
switch node {
|
|
||||||
case let node as TextNode:
|
|
||||||
let text: String
|
|
||||||
if usePreformattedText {
|
|
||||||
text = node.getWholeText()
|
|
||||||
} else {
|
|
||||||
text = node.text()
|
|
||||||
}
|
|
||||||
return NSAttributedString(string: text, attributes: [.font: defaultFont, .foregroundColor: defaultColor])
|
|
||||||
case let node as Element:
|
|
||||||
let attributed = NSMutableAttributedString(string: "", attributes: [.font: defaultFont, .foregroundColor: defaultColor])
|
|
||||||
for child in node.getChildNodes() {
|
|
||||||
attributed.append(attributedTextForHTMLNode(child, usePreformattedText: usePreformattedText || node.tagName() == "pre"))
|
|
||||||
}
|
|
||||||
|
|
||||||
switch node.tagName() {
|
|
||||||
case "br":
|
|
||||||
// need to specify defaultFont here b/c otherwise it uses the default 12pt Helvetica which
|
|
||||||
// screws up its determination of the line height making multiple lines of emojis squash together
|
|
||||||
attributed.append(NSAttributedString(string: "\n", attributes: [.font: defaultFont]))
|
|
||||||
case "a":
|
|
||||||
let href = try! node.attr("href")
|
|
||||||
if let webURL = WebURL(href),
|
|
||||||
let url = URL(webURL) {
|
|
||||||
attributed.addAttribute(.link, value: url, range: attributed.fullRange)
|
|
||||||
} else if let url = URL(string: href) {
|
|
||||||
attributed.addAttribute(.link, value: url, range: attributed.fullRange)
|
|
||||||
}
|
|
||||||
case "p":
|
|
||||||
attributed.append(NSAttributedString(string: "\n\n", attributes: [.font: defaultFont]))
|
|
||||||
case "em", "i":
|
|
||||||
let currentFont: UIFont
|
|
||||||
if attributed.length == 0 {
|
|
||||||
currentFont = defaultFont
|
|
||||||
} else {
|
|
||||||
currentFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? defaultFont
|
|
||||||
}
|
|
||||||
attributed.addAttribute(.font, value: currentFont.withTraits(.traitItalic)!, range: attributed.fullRange)
|
|
||||||
case "strong", "b":
|
|
||||||
let currentFont: UIFont
|
|
||||||
if attributed.length == 0 {
|
|
||||||
currentFont = defaultFont
|
|
||||||
} else {
|
|
||||||
currentFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? defaultFont
|
|
||||||
}
|
|
||||||
attributed.addAttribute(.font, value: currentFont.withTraits(.traitBold)!, range: attributed.fullRange)
|
|
||||||
case "del":
|
|
||||||
attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: attributed.fullRange)
|
|
||||||
case "code":
|
|
||||||
attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: defaultFont.pointSize, weight: .regular), range: attributed.fullRange)
|
|
||||||
case "pre":
|
|
||||||
attributed.append(NSAttributedString(string: "\n\n"))
|
|
||||||
attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: defaultFont.pointSize, weight: .regular), range: attributed.fullRange)
|
|
||||||
case "ol", "ul":
|
|
||||||
attributed.append(NSAttributedString(string: "\n\n"))
|
|
||||||
attributed.trimLeadingCharactersInSet(.whitespacesAndNewlines)
|
|
||||||
case "li":
|
|
||||||
let parentEl = node.parent()!
|
|
||||||
let parentTag = parentEl.tagName()
|
|
||||||
let bullet: NSAttributedString
|
|
||||||
if parentTag == "ol" {
|
|
||||||
let index = (try? node.elementSiblingIndex()) ?? 0
|
|
||||||
// we use the monospace digit font so that the periods of all the list items line up
|
|
||||||
bullet = NSAttributedString(string: "\(index + 1).\t", attributes: [.font: UIFont.monospacedDigitSystemFont(ofSize: defaultFont.pointSize, weight: .regular)])
|
|
||||||
} else if parentTag == "ul" {
|
|
||||||
bullet = NSAttributedString(string: "\u{2022}\t", attributes: [.font: defaultFont])
|
|
||||||
} else {
|
|
||||||
bullet = NSAttributedString()
|
|
||||||
}
|
|
||||||
attributed.insert(bullet, at: 0)
|
|
||||||
attributed.append(NSAttributedString(string: "\n", attributes: [.font: defaultFont]))
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return attributed
|
|
||||||
default:
|
|
||||||
fatalError("Unexpected node type \(type(of: node))")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Interaction
|
// MARK: - Interaction
|
||||||
|
|
|
@ -57,7 +57,7 @@ class BaseStatusTableViewCell: UITableViewCell {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private(set) var statusState: StatusState!
|
private(set) var statusState: CollapseState!
|
||||||
var collapsible = false {
|
var collapsible = false {
|
||||||
didSet {
|
didSet {
|
||||||
collapseButton.isHidden = !collapsible
|
collapseButton.isHidden = !collapsible
|
||||||
|
@ -130,7 +130,7 @@ class BaseStatusTableViewCell: UITableViewCell {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final func updateUI(statusID: String, state: StatusState) {
|
final func updateUI(statusID: String, state: CollapseState) {
|
||||||
createObserversIfNecessary()
|
createObserversIfNecessary()
|
||||||
|
|
||||||
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||||
|
@ -142,7 +142,7 @@ class BaseStatusTableViewCell: UITableViewCell {
|
||||||
doUpdateUI(status: status, state: state)
|
doUpdateUI(status: status, state: state)
|
||||||
}
|
}
|
||||||
|
|
||||||
func doUpdateUI(status: StatusMO, state: StatusState) {
|
func doUpdateUI(status: StatusMO, state: CollapseState) {
|
||||||
self.statusState = state
|
self.statusState = state
|
||||||
|
|
||||||
let account = status.account
|
let account = status.account
|
||||||
|
|
|
@ -80,7 +80,7 @@ class ConversationMainStatusTableViewCell: BaseStatusTableViewCell {
|
||||||
timestampAndClientLabel.adjustsFontForContentSizeCategory = true
|
timestampAndClientLabel.adjustsFontForContentSizeCategory = true
|
||||||
}
|
}
|
||||||
|
|
||||||
override func doUpdateUI(status: StatusMO, state: StatusState) {
|
override func doUpdateUI(status: StatusMO, state: CollapseState) {
|
||||||
super.doUpdateUI(status: status, state: state)
|
super.doUpdateUI(status: status, state: state)
|
||||||
|
|
||||||
var timestampAndClientText = ConversationMainStatusTableViewCell.dateFormatter.string(from: status.createdAt)
|
var timestampAndClientText = ConversationMainStatusTableViewCell.dateFormatter.string(from: status.createdAt)
|
||||||
|
|
|
@ -13,6 +13,7 @@ import Combine
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol StatusCollectionViewCellDelegate: AnyObject, TuskerNavigationDelegate, MenuActionProvider {
|
protocol StatusCollectionViewCellDelegate: AnyObject, TuskerNavigationDelegate, MenuActionProvider {
|
||||||
func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?)
|
func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?)
|
||||||
|
func statusCellShowFiltered(_ cell: StatusCollectionViewCell)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
@ -36,7 +37,7 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate
|
||||||
var showReplyIndicator: Bool { get }
|
var showReplyIndicator: Bool { get }
|
||||||
|
|
||||||
var statusID: String! { get set }
|
var statusID: String! { get set }
|
||||||
var statusState: StatusState! { get set }
|
var statusState: CollapseState! { get set }
|
||||||
var accountID: String! { get set }
|
var accountID: String! { get set }
|
||||||
|
|
||||||
var isGrayscale: Bool { get set }
|
var isGrayscale: Bool { get set }
|
||||||
|
|
|
@ -242,6 +242,18 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
[replyButton, favoriteButton, reblogButton, moreButton]
|
[replyButton, favoriteButton, reblogButton, moreButton]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var contentViewMode: ContentViewMode!
|
||||||
|
private let statusContainer = UIView().configure {
|
||||||
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
}
|
||||||
|
private let filteredLabel = UILabel().configure {
|
||||||
|
$0.textAlignment = .center
|
||||||
|
$0.textColor = .secondaryLabel
|
||||||
|
$0.font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitItalic)!, size: 0)
|
||||||
|
$0.adjustsFontForContentSizeCategory = true
|
||||||
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Cell state
|
// MARK: Cell state
|
||||||
|
|
||||||
private var mainContainerTopToReblogLabelConstraint: NSLayoutConstraint!
|
private var mainContainerTopToReblogLabelConstraint: NSLayoutConstraint!
|
||||||
|
@ -265,10 +277,11 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
|
|
||||||
// alas these need to be internal so they're accessible from the protocol extensions
|
// alas these need to be internal so they're accessible from the protocol extensions
|
||||||
var statusID: String!
|
var statusID: String!
|
||||||
var statusState: StatusState!
|
var statusState: CollapseState!
|
||||||
var accountID: String!
|
var accountID: String!
|
||||||
private var reblogStatusID: String?
|
private var reblogStatusID: String?
|
||||||
private var rebloggerID: String?
|
private var rebloggerID: String?
|
||||||
|
private var filterReason: String?
|
||||||
|
|
||||||
private var firstLayout = true
|
private var firstLayout = true
|
||||||
var isGrayscale = false
|
var isGrayscale = false
|
||||||
|
@ -279,33 +292,35 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
setContentViewMode(.status)
|
||||||
|
|
||||||
for subview in [timelineReasonHStack, mainContainer, actionsContainer] {
|
for subview in [timelineReasonHStack, mainContainer, actionsContainer] {
|
||||||
subview.translatesAutoresizingMaskIntoConstraints = false
|
subview.translatesAutoresizingMaskIntoConstraints = false
|
||||||
contentView.addSubview(subview)
|
statusContainer.addSubview(subview)
|
||||||
}
|
}
|
||||||
|
|
||||||
mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: timelineReasonHStack.bottomAnchor, constant: 4)
|
mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: timelineReasonHStack.bottomAnchor, constant: 4)
|
||||||
mainContainerTopToSelfConstraint = mainContainer.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8)
|
mainContainerTopToSelfConstraint = mainContainer.topAnchor.constraint(equalTo: statusContainer.topAnchor, constant: 8)
|
||||||
mainContainerBottomToActionsConstraint = mainContainer.bottomAnchor.constraint(equalTo: actionsContainer.topAnchor, constant: -4)
|
mainContainerBottomToActionsConstraint = mainContainer.bottomAnchor.constraint(equalTo: actionsContainer.topAnchor, constant: -4)
|
||||||
mainContainerBottomToSelfConstraint = mainContainer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6)
|
mainContainerBottomToSelfConstraint = mainContainer.bottomAnchor.constraint(equalTo: statusContainer.bottomAnchor, constant: -6)
|
||||||
|
|
||||||
let metaIndicatorsBottomConstraint = metaIndicatorsView.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -6)
|
let metaIndicatorsBottomConstraint = metaIndicatorsView.bottomAnchor.constraint(lessThanOrEqualTo: statusContainer.bottomAnchor, constant: -6)
|
||||||
// sometimes during intermediate layouts, there are conflicting constraints, so let this one get broken temporarily, to avoid a bunch of printing
|
// sometimes during intermediate layouts, there are conflicting constraints, so let this one get broken temporarily, to avoid a bunch of printing
|
||||||
metaIndicatorsBottomConstraint.priority = .init(999)
|
metaIndicatorsBottomConstraint.priority = .init(999)
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
// why is this 4 but the mainContainerTopSelfConstraint constant 8? because this looks more balanced
|
// why is this 4 but the mainContainerTopSelfConstraint constant 8? because this looks more balanced
|
||||||
timelineReasonHStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4),
|
timelineReasonHStack.topAnchor.constraint(equalTo: statusContainer.topAnchor, constant: 4),
|
||||||
timelineReasonLabel.leadingAnchor.constraint(equalTo: contentVStack.leadingAnchor),
|
timelineReasonLabel.leadingAnchor.constraint(equalTo: contentVStack.leadingAnchor),
|
||||||
timelineReasonHStack.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -16),
|
timelineReasonHStack.trailingAnchor.constraint(lessThanOrEqualTo: statusContainer.trailingAnchor, constant: -16),
|
||||||
|
|
||||||
mainContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
mainContainer.leadingAnchor.constraint(equalTo: statusContainer.leadingAnchor, constant: 16),
|
||||||
mainContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
|
mainContainer.trailingAnchor.constraint(equalTo: statusContainer.trailingAnchor, constant: -16),
|
||||||
|
|
||||||
actionsContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
actionsContainer.leadingAnchor.constraint(equalTo: statusContainer.leadingAnchor, constant: 16),
|
||||||
actionsContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
|
actionsContainer.trailingAnchor.constraint(equalTo: statusContainer.trailingAnchor, constant: -16),
|
||||||
// yes, this is deliberately 6. 4 looks to cramped, 8 looks uneven
|
// yes, this is deliberately 6. 4 looks to cramped, 8 looks uneven
|
||||||
actionsContainer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6),
|
actionsContainer.bottomAnchor.constraint(equalTo: statusContainer.bottomAnchor, constant: -6),
|
||||||
|
|
||||||
metaIndicatorsBottomConstraint,
|
metaIndicatorsBottomConstraint,
|
||||||
])
|
])
|
||||||
|
@ -342,6 +357,11 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
|
|
||||||
override var accessibilityAttributedLabel: NSAttributedString? {
|
override var accessibilityAttributedLabel: NSAttributedString? {
|
||||||
get {
|
get {
|
||||||
|
if contentViewMode == .filtered,
|
||||||
|
let filterReason {
|
||||||
|
return NSAttributedString(string: "Filtered: \(filterReason)")
|
||||||
|
}
|
||||||
|
|
||||||
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -381,7 +401,9 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
|
|
||||||
override var accessibilityHint: String? {
|
override var accessibilityHint: String? {
|
||||||
get {
|
get {
|
||||||
if statusState.collapsed ?? false {
|
if contentViewMode == .filtered {
|
||||||
|
return "Double tap to show the post."
|
||||||
|
} else if statusState.collapsed ?? false {
|
||||||
return "Double tap to expand the post."
|
return "Double tap to expand the post."
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
|
@ -391,7 +413,9 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
}
|
}
|
||||||
|
|
||||||
override func accessibilityActivate() -> Bool {
|
override func accessibilityActivate() -> Bool {
|
||||||
if statusState.collapsed ?? false {
|
if contentViewMode == .filtered {
|
||||||
|
delegate?.statusCellShowFiltered(self)
|
||||||
|
} else if statusState.collapsed ?? false {
|
||||||
toggleCollapse()
|
toggleCollapse()
|
||||||
} else {
|
} else {
|
||||||
delegate?.selected(status: statusID, state: statusState.copy())
|
delegate?.selected(status: statusID, state: statusState.copy())
|
||||||
|
@ -422,11 +446,58 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
|
|
||||||
// MARK: Configure UI
|
// MARK: Configure UI
|
||||||
|
|
||||||
func updateUI(statusID: String, state: StatusState) {
|
private func setContentViewMode(_ mode: ContentViewMode) {
|
||||||
|
guard mode != contentViewMode else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contentViewMode = mode
|
||||||
|
switch mode {
|
||||||
|
case .status:
|
||||||
|
// make the offscreen view transparent so the filtered -> status animation looks better
|
||||||
|
statusContainer.layer.opacity = 1
|
||||||
|
filteredLabel.layer.opacity = 0
|
||||||
|
filteredLabel.removeFromSuperview()
|
||||||
|
contentView.addSubview(statusContainer)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
statusContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||||
|
statusContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||||
|
statusContainer.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||||
|
statusContainer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||||
|
])
|
||||||
|
case .filtered:
|
||||||
|
statusContainer.layer.opacity = 0
|
||||||
|
filteredLabel.layer.opacity = 1
|
||||||
|
statusContainer.removeFromSuperview()
|
||||||
|
contentView.addSubview(filteredLabel)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
filteredLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||||
|
filteredLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||||
|
filteredLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.topAnchor, multiplier: 1),
|
||||||
|
contentView.bottomAnchor.constraint(equalToSystemSpacingBelow: filteredLabel.bottomAnchor, multiplier: 1),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func updateUI(statusID: String, state: CollapseState, filterResult: Filterer.Result) {
|
||||||
guard var status = mastodonController.persistentContainer.status(for: statusID) else {
|
guard var status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||||
fatalError()
|
fatalError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch filterResult {
|
||||||
|
case .allow:
|
||||||
|
setContentViewMode(.status)
|
||||||
|
case .warn(let filterTitle):
|
||||||
|
filterReason = filterTitle
|
||||||
|
let attrStr = NSMutableAttributedString(string: "Filtered: \(filterTitle) ")
|
||||||
|
let showStr = NSAttributedString(string: "Show", attributes: [.foregroundColor: UIColor.tintColor])
|
||||||
|
attrStr.append(showStr)
|
||||||
|
filteredLabel.attributedText = attrStr
|
||||||
|
setContentViewMode(.filtered)
|
||||||
|
case .hide:
|
||||||
|
fatalError("unreachable")
|
||||||
|
}
|
||||||
|
|
||||||
createObservers()
|
createObservers()
|
||||||
|
|
||||||
self.statusState = state
|
self.statusState = state
|
||||||
|
@ -659,6 +730,13 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension TimelineStatusCollectionViewCell {
|
||||||
|
private enum ContentViewMode {
|
||||||
|
case status
|
||||||
|
case filtered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension TimelineStatusCollectionViewCell: UIContextMenuInteractionDelegate {
|
extension TimelineStatusCollectionViewCell: UIContextMenuInteractionDelegate {
|
||||||
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
|
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
|
||||||
return contextMenuConfigurationForAccount(sourceView: interaction.view!)
|
return contextMenuConfigurationForAccount(sourceView: interaction.view!)
|
||||||
|
|
|
@ -110,7 +110,7 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func doUpdateUI(status: StatusMO, state: StatusState) {
|
override func doUpdateUI(status: StatusMO, state: CollapseState) {
|
||||||
var status = status
|
var status = status
|
||||||
|
|
||||||
if let rebloggedStatus = status.reblog {
|
if let rebloggedStatus = status.reblog {
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
//
|
||||||
|
// ZeroHeightCollectionViewCell.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/3/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class ZeroHeightCollectionViewCell: UICollectionViewCell {
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
heightAnchor.constraint(equalToConstant: 0).isActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
//
|
||||||
|
// SemiCaseSensitiveComparatorTests.swift
|
||||||
|
// TuskerTests
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/1/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import Tusker
|
||||||
|
|
||||||
|
final class SemiCaseSensitiveComparatorTests: XCTestCase {
|
||||||
|
|
||||||
|
func testCompare() {
|
||||||
|
let comparator = SemiCaseSensitiveComparator()
|
||||||
|
XCTAssertEqual(comparator.compare("a", "a"), .orderedSame)
|
||||||
|
XCTAssertEqual(comparator.compare("a", "A"), .orderedAscending)
|
||||||
|
XCTAssertEqual(comparator.compare("A", "a"), .orderedDescending)
|
||||||
|
XCTAssertEqual(comparator.compare("a", "B"), .orderedAscending)
|
||||||
|
XCTAssertEqual(comparator.compare("b", "A"), .orderedDescending)
|
||||||
|
XCTAssertEqual(["TEST", "Test", "test"].sorted(using: comparator), ["test", "Test", "TEST"])
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue