Compare commits
No commits in common. "e49725e06dd52c9dac421fd7a1c56717f62485ad" and "f23d3dfa3fb77423fb574a02363be0e839abfbc7" have entirely different histories.
e49725e06d
...
f23d3dfa3f
53
CHANGELOG.md
53
CHANGELOG.md
|
@ -1,57 +1,6 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2022.1 (49)
|
## 2022.1 (47)
|
||||||
The major new feature of this build is filters! Filters are editable by pressing the Filters button in the top-left corner of the Home tab. Filters are currently applied to timelines and profiles (filtering conversations and notifications will be added in a future build).
|
|
||||||
|
|
||||||
Features/Improvements:
|
|
||||||
- Filters
|
|
||||||
- Edit/create filters
|
|
||||||
- Apply filters to timelines and profiles
|
|
||||||
- Add preference to customize swipe actions on status cells (Preferences -> Appearance -> Leading/Trailing Swipe Actions)
|
|
||||||
- Show notifications for edited posts
|
|
||||||
- Add more details to the relationship label on profiles
|
|
||||||
- "Follows you", "You follow", "You follow each other", and "You block"
|
|
||||||
- Add state restoration for Federated and Local timelines
|
|
||||||
- Also fixes an issue where posts from other timelines would appear in the home timeline after a relaunch
|
|
||||||
- Add state restoration for Compose screen
|
|
||||||
- Organize expanded custom emoji picker by category
|
|
||||||
- Add indicator for locked profiles
|
|
||||||
- Add cache size info to Advanced preferences
|
|
||||||
- Indicate when a followed hashtag caused a post to appear in the home timeline
|
|
||||||
- Add hashtag follow/unfollow actions
|
|
||||||
- Completely replace timeline items after Jump to Present used
|
|
||||||
- Fixes infinite scroll not being usable after jumping
|
|
||||||
- iPad/Mac: Add Cmd+Return shortcut for sending post on the Compose screen
|
|
||||||
- VoiceOver: Hide redundant information on Compose screen
|
|
||||||
- VocieOver: Add links/mentions/hashtags to actions rotor on statuses
|
|
||||||
- VoiceOver: Add show profile action to timeline statuses
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
- Fix gifv attachments appearing off-center
|
|
||||||
- Fix long status on certain screens not getting collapsed
|
|
||||||
- Fix error when refreshing a timeline with no items
|
|
||||||
- Fix cells in account list and status action account list not being deselected when navigating back
|
|
||||||
- Limit search in Edit List screen to accounts the user follows
|
|
||||||
- Fix attachments disappearing from statuses on the Conversation screen
|
|
||||||
- Fix extra space gap appearing in profile headers below avatar
|
|
||||||
- Fix mute duration options not including 1 week
|
|
||||||
- Hometown: Fix local-only state not being copied from the replied-to post
|
|
||||||
- iPad: Fix timeline statuses not getting deselected when entering split navigation
|
|
||||||
- iPad: Fix Mute screen using pointless two-column layout
|
|
||||||
- iPad: Fix creating a list from the sidebar making the previous tab inaccessible
|
|
||||||
- iPad: Fix creating a list not showing the Edit List screen automatically
|
|
||||||
- iPad: Fix selected sidebar item becoming out-of-sync when deleting a list/hashtag/instance
|
|
||||||
- Mac: Workaround for pressing Cmd+1/2/... crashing
|
|
||||||
- This prevents the crash, but the actions remain unusable due to a macOS bug
|
|
||||||
- VoiceOver: Fix escape gesture not working on Compose screen
|
|
||||||
- VoiceOver: Fix links in conversation main status not being selectable
|
|
||||||
- VoiceOver: Fix profile relationship not being read
|
|
||||||
- VoiceOver: Fix not being able to select profile from conversation main status
|
|
||||||
|
|
||||||
Known Issues:
|
|
||||||
- Filters are not applied to notifications and conversations
|
|
||||||
|
|
||||||
## 2022.1 (48)
|
|
||||||
This build is a hotfix for the CW button in the Compose screen not working. The previous build's changelog is attached below.
|
This build is a hotfix for the CW button in the Compose screen not working. The previous build's changelog is attached below.
|
||||||
|
|
||||||
Bugfixes:
|
Bugfixes:
|
||||||
|
|
|
@ -229,25 +229,21 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Filters
|
// MARK: - Filters
|
||||||
public static func getFiltersV1() -> Request<[FilterV1]> {
|
public static func getFilters() -> Request<[Filter]> {
|
||||||
return Request<[FilterV1]>(method: .get, path: "/api/v1/filters")
|
return Request<[Filter]>(method: .get, path: "/api/v1/filters")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func createFilterV1(phrase: String, context: [FilterV1.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresIn: TimeInterval? = nil) -> Request<FilterV1> {
|
public static func createFilter(phrase: String, context: [Filter.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
|
||||||
return Request<FilterV1>(method: .post, path: "/api/v1/filters", body: ParametersBody([
|
return Request<Filter>(method: .post, path: "/api/v1/filters", body: ParametersBody([
|
||||||
"phrase" => phrase,
|
"phrase" => phrase,
|
||||||
"irreversible" => irreversible,
|
"irreversible" => irreversible,
|
||||||
"whole_word" => wholeWord,
|
"whole_word" => wholeWord,
|
||||||
"expires_in" => expiresIn,
|
"expires_at" => expiresAt
|
||||||
] + "context" => context.contextStrings))
|
] + "context" => context.contextStrings))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getFilterV1(id: String) -> Request<FilterV1> {
|
public static func getFilter(id: String) -> Request<Filter> {
|
||||||
return Request<FilterV1>(method: .get, path: "/api/v1/filters/\(id)")
|
return Request<Filter>(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
|
||||||
|
@ -265,10 +261,6 @@ public class Client {
|
||||||
return Request<Account>(method: .post, path: "/api/v1/follows", body: ParametersBody(["uri" => acct]))
|
return Request<Account>(method: .post, path: "/api/v1/follows", body: ParametersBody(["uri" => acct]))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getFollowedHashtags() -> Request<[Hashtag]> {
|
|
||||||
return Request(method: .get, path: "/api/v1/followed_tags")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Lists
|
// MARK: - Lists
|
||||||
public static func getLists() -> Request<[List]> {
|
public static func getLists() -> Request<[List]> {
|
||||||
return Request<[List]>(method: .get, path: "/api/v1/lists")
|
return Request<[List]>(method: .get, path: "/api/v1/lists")
|
||||||
|
@ -323,12 +315,11 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Search
|
// MARK: - Search
|
||||||
public static func search(query: String, types: [SearchResultType]? = nil, resolve: Bool? = nil, limit: Int? = nil, following: Bool? = nil) -> Request<SearchResults> {
|
public static func search(query: String, types: [SearchResultType]? = nil, resolve: Bool? = nil, limit: Int? = nil) -> Request<SearchResults> {
|
||||||
return Request<SearchResults>(method: .get, path: "/api/v2/search", queryParameters: [
|
return Request<SearchResults>(method: .get, path: "/api/v2/search", queryParameters: [
|
||||||
"q" => query,
|
"q" => query,
|
||||||
"resolve" => resolve,
|
"resolve" => resolve,
|
||||||
"limit" => limit,
|
"limit" => limit,
|
||||||
"following" => following,
|
|
||||||
] + "types" => types?.map { $0.rawValue })
|
] + "types" => types?.map { $0.rawValue })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,6 @@ public class Emoji: Codable {
|
||||||
public let url: WebURL
|
public let url: WebURL
|
||||||
public let staticURL: WebURL
|
public let staticURL: WebURL
|
||||||
public let visibleInPicker: Bool
|
public let visibleInPicker: Bool
|
||||||
public let category: String?
|
|
||||||
|
|
||||||
public required init(from decoder: Decoder) throws {
|
public required init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
@ -25,7 +24,6 @@ public class Emoji: Codable {
|
||||||
self.url = try container.decode(WebURL.self, forKey: .url)
|
self.url = try container.decode(WebURL.self, forKey: .url)
|
||||||
self.staticURL = try container.decode(WebURL.self, forKey: .staticURL)
|
self.staticURL = try container.decode(WebURL.self, forKey: .staticURL)
|
||||||
self.visibleInPicker = try container.decode(Bool.self, forKey: .visibleInPicker)
|
self.visibleInPicker = try container.decode(Bool.self, forKey: .visibleInPicker)
|
||||||
self.category = try container.decodeIfPresent(String.self, forKey: .category)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
@ -33,7 +31,6 @@ public class Emoji: Codable {
|
||||||
case url
|
case url
|
||||||
case staticURL = "static_url"
|
case staticURL = "static_url"
|
||||||
case visibleInPicker = "visible_in_picker"
|
case visibleInPicker = "visible_in_picker"
|
||||||
case category
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// FilterV1.swift
|
// Filter.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 struct FilterV1: Decodable {
|
public class Filter: 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,16 +22,17 @@ public struct FilterV1: Decodable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func update(_ filterID: String, phrase: String, context: [Context], irreversible: Bool, wholeWord: Bool, expiresIn: TimeInterval?) -> Request<FilterV1> {
|
public static func update(_ filter: Filter, phrase: String? = nil, context: [Context]? = nil, irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
|
||||||
return Request<FilterV1>(method: .put, path: "/api/v1/filters/\(filterID)", body: ParametersBody([
|
return Request<Filter>(method: .put, path: "/api/v1/filters/\(filter.id)", body: ParametersBody([
|
||||||
"phrase" => phrase,
|
"phrase" => (phrase ?? filter.phrase),
|
||||||
"whole_word" => wholeWord,
|
"irreversible" => (irreversible ?? filter.irreversible),
|
||||||
"expires_in" => expiresIn,
|
"whole_word" => (wholeWord ?? filter.wholeWord),
|
||||||
] + "context" => context.contextStrings))
|
"expires_at" => (expiresAt ?? filter.expiresAt)
|
||||||
|
] + "context" => (context?.contextStrings ?? filter.context)))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func delete(_ filterID: String) -> Request<Empty> {
|
public static func delete(_ filter: Filter) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .delete, path: "/api/v1/filters/\(filterID)")
|
return Request<Empty>(method: .delete, path: "/api/v1/filters/\(filter.id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
@ -44,17 +45,16 @@ public struct FilterV1: Decodable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension FilterV1 {
|
extension Filter {
|
||||||
public enum Context: String, Decodable, CaseIterable {
|
public enum Context: String, Decodable {
|
||||||
case home
|
case home
|
||||||
case notifications
|
case notifications
|
||||||
case `public`
|
case `public`
|
||||||
case thread
|
case thread
|
||||||
case account
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Array where Element == FilterV1.Context {
|
extension Array where Element == Filter.Context {
|
||||||
var contextStrings: [String] {
|
var contextStrings: [String] {
|
||||||
return map { $0.rawValue }
|
return map { $0.rawValue }
|
||||||
}
|
}
|
|
@ -1,115 +0,0 @@
|
||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -15,14 +15,11 @@ public class Hashtag: Codable {
|
||||||
public let url: WebURL
|
public let url: WebURL
|
||||||
/// Only present when returned from the trending hashtags endpoint
|
/// Only present when returned from the trending hashtags endpoint
|
||||||
public let history: [History]?
|
public let history: [History]?
|
||||||
/// Only present on Mastodon >= 4 and when logged in
|
|
||||||
public let following: Bool?
|
|
||||||
|
|
||||||
public init(name: String, url: URL) {
|
public init(name: String, url: URL) {
|
||||||
self.name = name
|
self.name = name
|
||||||
self.url = WebURL(url)!
|
self.url = WebURL(url)!
|
||||||
self.history = nil
|
self.history = nil
|
||||||
self.following = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public required init(from decoder: Decoder) throws {
|
public required init(from decoder: Decoder) throws {
|
||||||
|
@ -31,7 +28,6 @@ public class Hashtag: Codable {
|
||||||
// pixelfed (possibly others) don't fully escape special characters in the hashtag url
|
// pixelfed (possibly others) don't fully escape special characters in the hashtag url
|
||||||
self.url = try container.decode(WebURL.self, forKey: .url)
|
self.url = try container.decode(WebURL.self, forKey: .url)
|
||||||
self.history = try container.decodeIfPresent([History].self, forKey: .history)
|
self.history = try container.decodeIfPresent([History].self, forKey: .history)
|
||||||
self.following = try container.decodeIfPresent(Bool.self, forKey: .following)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func encode(to encoder: Encoder) throws {
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
@ -39,22 +35,12 @@ public class Hashtag: Codable {
|
||||||
try container.encode(name, forKey: .name)
|
try container.encode(name, forKey: .name)
|
||||||
try container.encode(url, forKey: .url)
|
try container.encode(url, forKey: .url)
|
||||||
try container.encodeIfPresent(history, forKey: .history)
|
try container.encodeIfPresent(history, forKey: .history)
|
||||||
try container.encodeIfPresent(following, forKey: .following)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func follow(name: String) -> Request<Hashtag> {
|
|
||||||
return Request(method: .post, path: "/api/v1/tags/\(name)/follow")
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func unfollow(name: String) -> Request<Hashtag> {
|
|
||||||
return Request(method: .post, path: "/api/v1/tags/\(name)/unfollow")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case name
|
case name
|
||||||
case url
|
case url
|
||||||
case history
|
case history
|
||||||
case following
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -56,7 +56,6 @@ extension Notification {
|
||||||
case follow
|
case follow
|
||||||
case followRequest = "follow_request"
|
case followRequest = "follow_request"
|
||||||
case poll
|
case poll
|
||||||
case update
|
|
||||||
case unknown
|
case unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum Timeline: Equatable {
|
public enum Timeline {
|
||||||
case home
|
case home
|
||||||
case `public`(local: Bool)
|
case `public`(local: Bool)
|
||||||
case tag(hashtag: String)
|
case tag(hashtag: String)
|
||||||
|
|
|
@ -42,10 +42,6 @@ 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)")
|
||||||
|
|
|
@ -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: CollapseState?
|
public let statusState: StatusState?
|
||||||
|
|
||||||
init?(notifications: [Notification]) {
|
init?(notifications: [Notification]) {
|
||||||
guard !notifications.isEmpty else { return nil }
|
guard !notifications.isEmpty else { return nil }
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// CollapseState.swift
|
// StatusState.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 CollapseState: Equatable {
|
public class StatusState: Equatable {
|
||||||
public var collapsible: Bool?
|
public var collapsible: Bool?
|
||||||
public var collapsed: Bool?
|
public var collapsed: Bool?
|
||||||
|
|
||||||
|
@ -21,8 +21,8 @@ public class CollapseState: Equatable {
|
||||||
self.collapsed = collapsed
|
self.collapsed = collapsed
|
||||||
}
|
}
|
||||||
|
|
||||||
public func copy() -> CollapseState {
|
public func copy() -> StatusState {
|
||||||
return CollapseState(collapsible: self.collapsible, collapsed: self.collapsed)
|
return StatusState(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 CollapseState: Equatable {
|
||||||
hasher.combine(collapsed)
|
hasher.combine(collapsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static var unknown: CollapseState {
|
public static var unknown: StatusState {
|
||||||
CollapseState(collapsible: nil, collapsed: nil)
|
StatusState(collapsible: nil, collapsed: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func == (lhs: CollapseState, rhs: CollapseState) -> Bool {
|
public static func == (lhs: StatusState, rhs: StatusState) -> Bool {
|
||||||
lhs.collapsible == rhs.collapsible && lhs.collapsed == rhs.collapsed
|
lhs.collapsible == rhs.collapsible && lhs.collapsed == rhs.collapsed
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -29,11 +29,11 @@ public protocol DuckableViewControllerDelegate: AnyObject {
|
||||||
|
|
||||||
extension UIViewController {
|
extension UIViewController {
|
||||||
@available(iOS 16.0, *)
|
@available(iOS 16.0, *)
|
||||||
public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false) -> Bool {
|
public func presentDuckable(_ viewController: DuckableViewController) -> Bool {
|
||||||
var cur: UIViewController? = self
|
var cur: UIViewController? = self
|
||||||
while let vc = cur {
|
while let vc = cur {
|
||||||
if let container = vc as? DuckableContainerViewController {
|
if let container = vc as? DuckableContainerViewController {
|
||||||
container.presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: nil)
|
container.presentDuckable(viewController, animated: true, completion: nil)
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
cur = vc.parent
|
cur = vc.parent
|
||||||
|
|
|
@ -17,14 +17,6 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
|
||||||
private var bottomConstraint: NSLayoutConstraint!
|
private var bottomConstraint: NSLayoutConstraint!
|
||||||
private(set) var state = State.idle
|
private(set) var state = State.idle
|
||||||
|
|
||||||
public var duckedViewController: DuckableViewController? {
|
|
||||||
if case .ducked(let vc, placeholder: _) = state {
|
|
||||||
return vc
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(child: UIViewController) {
|
public init(child: UIViewController) {
|
||||||
self.child = child
|
self.child = child
|
||||||
|
|
||||||
|
@ -58,7 +50,7 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool, completion: (() -> Void)?) {
|
func presentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
|
||||||
guard case .idle = state else {
|
guard case .idle = state else {
|
||||||
if animated,
|
if animated,
|
||||||
case .ducked(_, placeholder: let placeholder) = state {
|
case .ducked(_, placeholder: let placeholder) = state {
|
||||||
|
@ -77,14 +69,9 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if isDucked {
|
|
||||||
state = .ducked(viewController, placeholder: createPlaceholderForDuckedViewController(viewController))
|
|
||||||
configureChildForDuckedPlaceholder()
|
|
||||||
} else {
|
|
||||||
state = .presentingDucked(viewController, isFirstPresentation: true)
|
state = .presentingDucked(viewController, isFirstPresentation: true)
|
||||||
doPresentDuckable(viewController, animated: animated, completion: completion)
|
doPresentDuckable(viewController, animated: animated, completion: completion)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
|
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
|
||||||
viewController.duckableDelegate = self
|
viewController.duckableDelegate = self
|
||||||
|
@ -92,7 +79,9 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
|
||||||
nav.modalPresentationStyle = .custom
|
nav.modalPresentationStyle = .custom
|
||||||
nav.transitioningDelegate = self
|
nav.transitioningDelegate = self
|
||||||
present(nav, animated: animated) {
|
present(nav, animated: animated) {
|
||||||
self.configureChildForDuckedPlaceholder()
|
self.bottomConstraint.isActive = false
|
||||||
|
self.bottomConstraint = self.child.view.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -detentHeight - 4)
|
||||||
|
self.bottomConstraint.isActive = true
|
||||||
completion?()
|
completion?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -138,18 +127,10 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
|
||||||
}
|
}
|
||||||
let placeholder = createPlaceholderForDuckedViewController(viewController)
|
let placeholder = createPlaceholderForDuckedViewController(viewController)
|
||||||
state = .ducked(viewController, placeholder: placeholder)
|
state = .ducked(viewController, placeholder: placeholder)
|
||||||
configureChildForDuckedPlaceholder()
|
|
||||||
dismiss(animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func configureChildForDuckedPlaceholder() {
|
|
||||||
bottomConstraint.isActive = false
|
|
||||||
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -detentHeight - 4)
|
|
||||||
bottomConstraint.isActive = true
|
|
||||||
|
|
||||||
child.view.layer.cornerRadius = duckedCornerRadius
|
child.view.layer.cornerRadius = duckedCornerRadius
|
||||||
child.view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
child.view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
||||||
child.view.layer.masksToBounds = true
|
child.view.layer.masksToBounds = true
|
||||||
|
dismiss(animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func unduckViewController() {
|
@objc func unduckViewController() {
|
||||||
|
@ -210,10 +191,7 @@ extension DuckableContainerViewController: UIViewControllerTransitioningDelegate
|
||||||
@available(iOS 16.0, *)
|
@available(iOS 16.0, *)
|
||||||
extension DuckableContainerViewController: UISheetPresentationControllerDelegate {
|
extension DuckableContainerViewController: UISheetPresentationControllerDelegate {
|
||||||
public func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) {
|
public func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) {
|
||||||
guard let snapshot = child.view.snapshotView(afterScreenUpdates: false) else {
|
let snapshot = child.view.snapshotView(afterScreenUpdates: false)!
|
||||||
setOverrideTraitCollection(UITraitCollection(userInterfaceLevel: .elevated), forChild: child)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
snapshot.translatesAutoresizingMaskIntoConstraints = false
|
snapshot.translatesAutoresizingMaskIntoConstraints = false
|
||||||
self.view.addSubview(snapshot)
|
self.view.addSubview(snapshot)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
|
|
|
@ -44,30 +44,6 @@
|
||||||
D61DC84628F498F200B82C6E /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84528F498F200B82C6E /* Logging.swift */; };
|
D61DC84628F498F200B82C6E /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84528F498F200B82C6E /* Logging.swift */; };
|
||||||
D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */; };
|
D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */; };
|
||||||
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84C28F500D200B82C6E /* ProfileViewController.swift */; };
|
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84C28F500D200B82C6E /* ProfileViewController.swift */; };
|
||||||
D61F75882932DB6000C0B37F /* StatusSwipeAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75872932DB6000C0B37F /* StatusSwipeAction.swift */; };
|
|
||||||
D61F758A2932E1FC00C0B37F /* SwipeActionsPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75892932E1FC00C0B37F /* SwipeActionsPrefsView.swift */; };
|
|
||||||
D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F758B2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift */; };
|
|
||||||
D61F758E2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */; };
|
|
||||||
D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F758F29353B4300C0B37F /* FileManager+Size.swift */; };
|
|
||||||
D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759129365C6C00C0B37F /* CollectionViewController.swift */; };
|
|
||||||
D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75932936F0DA00C0B37F /* FollowedHashtag.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 */; };
|
|
||||||
D61F75BD293D099600C0B37F /* Lazy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75BC293D099600C0B37F /* Lazy.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 */; };
|
||||||
|
@ -310,7 +286,7 @@
|
||||||
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */; };
|
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */; };
|
||||||
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */; };
|
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */; };
|
||||||
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */; };
|
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */; };
|
||||||
D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* Weak.swift */; };
|
D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */; };
|
||||||
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E0DC8D216EDF1E00369478 /* Previewing.swift */; };
|
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E0DC8D216EDF1E00369478 /* Previewing.swift */; };
|
||||||
D6E343AB265AAD6B00C4AA01 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D6E343AA265AAD6B00C4AA01 /* Media.xcassets */; };
|
D6E343AB265AAD6B00C4AA01 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D6E343AA265AAD6B00C4AA01 /* Media.xcassets */; };
|
||||||
D6E343AD265AAD6B00C4AA01 /* ActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E343AC265AAD6B00C4AA01 /* ActionViewController.swift */; };
|
D6E343AD265AAD6B00C4AA01 /* ActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E343AC265AAD6B00C4AA01 /* ActionViewController.swift */; };
|
||||||
|
@ -335,6 +311,8 @@
|
||||||
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; };
|
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; };
|
||||||
D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; };
|
D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; };
|
||||||
D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; };
|
D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; };
|
||||||
|
D6F6A54C291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A54B291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift */; };
|
||||||
|
D6F6A54E291EF7E100F496A8 /* EditListSearchFollowingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A54D291EF7E100F496A8 /* EditListSearchFollowingViewController.swift */; };
|
||||||
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A54F291F058600F496A8 /* CreateListService.swift */; };
|
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A54F291F058600F496A8 /* CreateListService.swift */; };
|
||||||
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A551291F098700F496A8 /* RenameListService.swift */; };
|
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A551291F098700F496A8 /* RenameListService.swift */; };
|
||||||
D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A553291F0D9600F496A8 /* DeleteListService.swift */; };
|
D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A553291F0D9600F496A8 /* DeleteListService.swift */; };
|
||||||
|
@ -428,30 +406,6 @@
|
||||||
D61DC84528F498F200B82C6E /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = "<group>"; };
|
D61DC84528F498F200B82C6E /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = "<group>"; };
|
||||||
D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderCollectionViewCell.swift; sourceTree = "<group>"; };
|
D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
D61DC84C28F500D200B82C6E /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
|
D61DC84C28F500D200B82C6E /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
|
||||||
D61F75872932DB6000C0B37F /* StatusSwipeAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusSwipeAction.swift; sourceTree = "<group>"; };
|
|
||||||
D61F75892932E1FC00C0B37F /* SwipeActionsPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeActionsPrefsView.swift; sourceTree = "<group>"; };
|
|
||||||
D61F758B2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusUpdatedNotificationTableViewCell.swift; sourceTree = "<group>"; };
|
|
||||||
D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusUpdatedNotificationTableViewCell.xib; sourceTree = "<group>"; };
|
|
||||||
D61F758F29353B4300C0B37F /* FileManager+Size.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Size.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>"; };
|
|
||||||
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>"; };
|
|
||||||
D61F75BC293D099600C0B37F /* Lazy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lazy.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>"; };
|
||||||
|
@ -702,7 +656,7 @@
|
||||||
D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Notification.swift"; sourceTree = "<group>"; };
|
D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Notification.swift"; sourceTree = "<group>"; };
|
||||||
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelationshipMO.swift; sourceTree = "<group>"; };
|
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelationshipMO.swift; sourceTree = "<group>"; };
|
||||||
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadScrollGestureRecognizer.swift; sourceTree = "<group>"; };
|
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadScrollGestureRecognizer.swift; sourceTree = "<group>"; };
|
||||||
D6DFC69F242C4CCC00ACC392 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = "<group>"; };
|
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakArray.swift; sourceTree = "<group>"; };
|
||||||
D6E0DC8D216EDF1E00369478 /* Previewing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Previewing.swift; sourceTree = "<group>"; };
|
D6E0DC8D216EDF1E00369478 /* Previewing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Previewing.swift; sourceTree = "<group>"; };
|
||||||
D6E343A8265AAD6B00C4AA01 /* OpenInTusker.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenInTusker.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
D6E343A8265AAD6B00C4AA01 /* OpenInTusker.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenInTusker.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
D6E343AA265AAD6B00C4AA01 /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; };
|
D6E343AA265AAD6B00C4AA01 /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; };
|
||||||
|
@ -730,6 +684,8 @@
|
||||||
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = "<group>"; };
|
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = "<group>"; };
|
||||||
D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReporterViewController.swift; sourceTree = "<group>"; };
|
D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReporterViewController.swift; sourceTree = "<group>"; };
|
||||||
D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IssueReporterViewController.xib; sourceTree = "<group>"; };
|
D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IssueReporterViewController.xib; sourceTree = "<group>"; };
|
||||||
|
D6F6A54B291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListSearchResultsContainerViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D6F6A54D291EF7E100F496A8 /* EditListSearchFollowingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListSearchFollowingViewController.swift; sourceTree = "<group>"; };
|
||||||
D6F6A54F291F058600F496A8 /* CreateListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateListService.swift; sourceTree = "<group>"; };
|
D6F6A54F291F058600F496A8 /* CreateListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateListService.swift; sourceTree = "<group>"; };
|
||||||
D6F6A551291F098700F496A8 /* RenameListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameListService.swift; sourceTree = "<group>"; };
|
D6F6A551291F098700F496A8 /* RenameListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameListService.swift; sourceTree = "<group>"; };
|
||||||
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteListService.swift; sourceTree = "<group>"; };
|
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteListService.swift; sourceTree = "<group>"; };
|
||||||
|
@ -807,7 +763,6 @@
|
||||||
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>";
|
||||||
|
@ -821,16 +776,6 @@
|
||||||
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 = (
|
||||||
|
@ -904,6 +849,8 @@
|
||||||
children = (
|
children = (
|
||||||
D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */,
|
D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */,
|
||||||
D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */,
|
D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */,
|
||||||
|
D6F6A54B291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift */,
|
||||||
|
D6F6A54D291EF7E100F496A8 /* EditListSearchFollowingViewController.swift */,
|
||||||
);
|
);
|
||||||
path = Lists;
|
path = Lists;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -928,9 +875,6 @@
|
||||||
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */,
|
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */,
|
||||||
D6B9366E2828452F00237D0E /* SavedHashtag.swift */,
|
D6B9366E2828452F00237D0E /* SavedHashtag.swift */,
|
||||||
D6B9366C2828444F00237D0E /* SavedInstance.swift */,
|
D6B9366C2828444F00237D0E /* SavedInstance.swift */,
|
||||||
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */,
|
|
||||||
D61F759A29384F9C00C0B37F /* FilterMO.swift */,
|
|
||||||
D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */,
|
|
||||||
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
|
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
|
||||||
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
|
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
|
||||||
);
|
);
|
||||||
|
@ -960,7 +904,6 @@
|
||||||
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 */,
|
||||||
|
@ -1088,7 +1031,6 @@
|
||||||
D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */,
|
D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */,
|
||||||
04586B4022B2FFB10021BD04 /* PreferencesView.swift */,
|
04586B4022B2FFB10021BD04 /* PreferencesView.swift */,
|
||||||
04586B4222B301470021BD04 /* AppearancePrefsView.swift */,
|
04586B4222B301470021BD04 /* AppearancePrefsView.swift */,
|
||||||
D61F75892932E1FC00C0B37F /* SwipeActionsPrefsView.swift */,
|
|
||||||
0427033722B30F5F000D31B6 /* BehaviorPrefsView.swift */,
|
0427033722B30F5F000D31B6 /* BehaviorPrefsView.swift */,
|
||||||
D6B17254254F88B800128392 /* OppositeCollapseKeywordsView.swift */,
|
D6B17254254F88B800128392 /* OppositeCollapseKeywordsView.swift */,
|
||||||
D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */,
|
D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */,
|
||||||
|
@ -1139,8 +1081,6 @@
|
||||||
D64BC18E23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib */,
|
D64BC18E23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib */,
|
||||||
D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */,
|
D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */,
|
||||||
D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */,
|
D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */,
|
||||||
D61F758B2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift */,
|
|
||||||
D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */,
|
|
||||||
);
|
);
|
||||||
path = Notifications;
|
path = Notifications;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1193,7 +1133,6 @@
|
||||||
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */,
|
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */,
|
||||||
D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */,
|
D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */,
|
||||||
D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */,
|
D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */,
|
||||||
D61F75872932DB6000C0B37F /* StatusSwipeAction.swift */,
|
|
||||||
);
|
);
|
||||||
path = Preferences;
|
path = Preferences;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1221,8 +1160,6 @@
|
||||||
D62E9984279CA23900C26176 /* URLSession+Development.swift */,
|
D62E9984279CA23900C26176 /* URLSession+Development.swift */,
|
||||||
D6ADB6ED28EA74E8009924AB /* UIView+Configure.swift */,
|
D6ADB6ED28EA74E8009924AB /* UIView+Configure.swift */,
|
||||||
D63CC70F2911F1E4000E19DE /* UIScrollView+Top.swift */,
|
D63CC70F2911F1E4000E19DE /* UIScrollView+Top.swift */,
|
||||||
D61F758F29353B4300C0B37F /* FileManager+Size.swift */,
|
|
||||||
D61F75AC293AF39000C0B37F /* Filter+Helpers.swift */,
|
|
||||||
);
|
);
|
||||||
path = Extensions;
|
path = Extensions;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1353,7 +1290,6 @@
|
||||||
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 */,
|
||||||
|
@ -1412,7 +1348,6 @@
|
||||||
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
|
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
|
||||||
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */,
|
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */,
|
||||||
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */,
|
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */,
|
||||||
D61F759129365C6C00C0B37F /* CollectionViewController.swift */,
|
|
||||||
);
|
);
|
||||||
path = Utilities;
|
path = Utilities;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1461,21 +1396,17 @@
|
||||||
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 */,
|
||||||
D61F75BC293D099600C0B37F /* Lazy.swift */,
|
|
||||||
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
|
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
|
||||||
D64D0AAC2128D88B005A6F37 /* LocalData.swift */,
|
D64D0AAC2128D88B005A6F37 /* LocalData.swift */,
|
||||||
D61DC84528F498F200B82C6E /* Logging.swift */,
|
D61DC84528F498F200B82C6E /* Logging.swift */,
|
||||||
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 /* WeakArray.swift */,
|
||||||
D63D8DF32850FE7A008D95E1 /* ViewTags.swift */,
|
D63D8DF32850FE7A008D95E1 /* ViewTags.swift */,
|
||||||
D6D4DDD6212518A200E1C4BB /* Assets.xcassets */,
|
D6D4DDD6212518A200E1C4BB /* Assets.xcassets */,
|
||||||
D6AEBB3F2321640F00E5038B /* Activities */,
|
D6AEBB3F2321640F00E5038B /* Activities */,
|
||||||
|
@ -1503,7 +1434,6 @@
|
||||||
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;
|
||||||
|
@ -1584,10 +1514,6 @@
|
||||||
D6F6A54F291F058600F496A8 /* CreateListService.swift */,
|
D6F6A54F291F058600F496A8 /* CreateListService.swift */,
|
||||||
D6F6A551291F098700F496A8 /* RenameListService.swift */,
|
D6F6A551291F098700F496A8 /* RenameListService.swift */,
|
||||||
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */,
|
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */,
|
||||||
D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */,
|
|
||||||
D61F75B0293BD85300C0B37F /* CreateFilterService.swift */,
|
|
||||||
D61F75B2293BD89C00C0B37F /* UpdateFilterService.swift */,
|
|
||||||
D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */,
|
|
||||||
);
|
);
|
||||||
path = API;
|
path = API;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1772,7 +1698,6 @@
|
||||||
D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */,
|
D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */,
|
||||||
D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */,
|
D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */,
|
||||||
D662AEF0263A3B880082A153 /* PollFinishedTableViewCell.xib in Resources */,
|
D662AEF0263A3B880082A153 /* PollFinishedTableViewCell.xib in Resources */,
|
||||||
D61F758E2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib in Resources */,
|
|
||||||
D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */,
|
D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
@ -1858,6 +1783,7 @@
|
||||||
D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */,
|
D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */,
|
||||||
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */,
|
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */,
|
||||||
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
|
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
|
||||||
|
D6F6A54C291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift in Sources */,
|
||||||
D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */,
|
D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */,
|
||||||
D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */,
|
D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */,
|
||||||
D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */,
|
D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */,
|
||||||
|
@ -1865,11 +1791,8 @@
|
||||||
D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */,
|
D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */,
|
||||||
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */,
|
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */,
|
||||||
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */,
|
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.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 */,
|
||||||
|
@ -1882,14 +1805,12 @@
|
||||||
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 */,
|
||||||
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */,
|
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */,
|
||||||
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */,
|
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */,
|
||||||
D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */,
|
D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */,
|
||||||
D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */,
|
|
||||||
D63CC7122911F57C000E19DE /* StatusBarTappableViewController.swift in Sources */,
|
D63CC7122911F57C000E19DE /* StatusBarTappableViewController.swift in Sources */,
|
||||||
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */,
|
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */,
|
||||||
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */,
|
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */,
|
||||||
|
@ -1925,11 +1846,9 @@
|
||||||
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 */,
|
||||||
D61F758A2932E1FC00C0B37F /* SwipeActionsPrefsView.swift in Sources */,
|
|
||||||
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */,
|
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */,
|
||||||
D662AEF2263A4BE10082A153 /* ComposePollView.swift in Sources */,
|
D662AEF2263A4BE10082A153 /* ComposePollView.swift in Sources */,
|
||||||
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */,
|
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */,
|
||||||
|
@ -1953,7 +1872,6 @@
|
||||||
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 */,
|
||||||
|
@ -1971,7 +1889,6 @@
|
||||||
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 */,
|
||||||
|
@ -2001,19 +1918,16 @@
|
||||||
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 */,
|
||||||
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
|
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
|
||||||
D6B93667281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift in Sources */,
|
D6B93667281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift in Sources */,
|
||||||
D61F75BD293D099600C0B37F /* Lazy.swift in Sources */,
|
|
||||||
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */,
|
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */,
|
||||||
D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */,
|
D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */,
|
||||||
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */,
|
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */,
|
||||||
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */,
|
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */,
|
||||||
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */,
|
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */,
|
||||||
D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */,
|
|
||||||
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */,
|
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */,
|
||||||
D627943923A553B600D38C68 /* UnbookmarkStatusActivity.swift in Sources */,
|
D627943923A553B600D38C68 /* UnbookmarkStatusActivity.swift in Sources */,
|
||||||
D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */,
|
D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */,
|
||||||
|
@ -2030,7 +1944,6 @@
|
||||||
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 */,
|
||||||
|
@ -2039,7 +1952,6 @@
|
||||||
D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */,
|
D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */,
|
||||||
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
|
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
|
||||||
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
|
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
|
||||||
D61F75882932DB6000C0B37F /* StatusSwipeAction.swift in Sources */,
|
|
||||||
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListViewController.swift in Sources */,
|
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListViewController.swift in Sources */,
|
||||||
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
|
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
|
||||||
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
|
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
|
||||||
|
@ -2060,15 +1972,11 @@
|
||||||
D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */,
|
D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */,
|
||||||
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */,
|
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */,
|
||||||
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */,
|
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */,
|
||||||
D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */,
|
|
||||||
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
|
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
|
||||||
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,
|
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,
|
||||||
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */,
|
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */,
|
||||||
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 */,
|
|
||||||
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 */,
|
||||||
|
@ -2080,9 +1988,8 @@
|
||||||
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 /* WeakArray.swift in Sources */,
|
||||||
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */,
|
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */,
|
||||||
D61ABEF828EFC3F900B29151 /* ProfileStatusesViewController.swift in Sources */,
|
D61ABEF828EFC3F900B29151 /* ProfileStatusesViewController.swift in Sources */,
|
||||||
D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */,
|
D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */,
|
||||||
|
@ -2092,8 +1999,6 @@
|
||||||
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 */,
|
||||||
|
@ -2114,6 +2019,7 @@
|
||||||
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
|
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
|
||||||
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,
|
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,
|
||||||
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */,
|
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */,
|
||||||
|
D6F6A54E291EF7E100F496A8 /* EditListSearchFollowingViewController.swift in Sources */,
|
||||||
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */,
|
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */,
|
||||||
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */,
|
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */,
|
||||||
D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */,
|
D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */,
|
||||||
|
@ -2128,7 +2034,6 @@
|
||||||
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 */,
|
||||||
|
@ -2145,7 +2050,6 @@
|
||||||
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 */,
|
||||||
|
@ -2291,7 +2195,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 49;
|
CURRENT_PROJECT_VERSION = 48;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
@ -2359,7 +2263,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 49;
|
CURRENT_PROJECT_VERSION = 48;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||||
|
@ -2509,7 +2413,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 49;
|
CURRENT_PROJECT_VERSION = 48;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
@ -2538,7 +2442,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 49;
|
CURRENT_PROJECT_VERSION = 48;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
@ -2648,7 +2552,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 49;
|
CURRENT_PROJECT_VERSION = 48;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||||
|
@ -2675,7 +2579,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 49;
|
CURRENT_PROJECT_VERSION = 48;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||||
|
|
|
@ -1,41 +0,0 @@
|
||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,29 +0,0 @@
|
||||||
//
|
|
||||||
// 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 && hasMastodonVersion(3, 5, 0)
|
instanceType.isMastodon && hasVersion(3, 5, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
var reblogVisibility: Bool {
|
var reblogVisibility: Bool {
|
||||||
(instanceType.isMastodon && hasMastodonVersion(2, 8, 0))
|
(instanceType.isMastodon && hasVersion(2, 8, 0))
|
||||||
|| (instanceType.isPleroma && hasPleromaVersion(2, 0, 0))
|
|| (instanceType.isPleroma && hasPleromaVersion(2, 0, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,14 +84,6 @@ struct InstanceFeatures {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var canFollowHashtags: Bool {
|
|
||||||
hasMastodonVersion(4, 0, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
var filtersV2: Bool {
|
|
||||||
hasMastodonVersion(4, 0, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func update(instance: Instance, nodeInfo: NodeInfo?) {
|
mutating func update(instance: Instance, nodeInfo: NodeInfo?) {
|
||||||
let ver = instance.version.lowercased()
|
let ver = instance.version.lowercased()
|
||||||
if ver.contains("glitch") {
|
if ver.contains("glitch") {
|
||||||
|
@ -99,20 +91,11 @@ struct InstanceFeatures {
|
||||||
} else if nodeInfo?.software.name == "hometown" {
|
} else if nodeInfo?.software.name == "hometown" {
|
||||||
var mastoVersion: Version?
|
var mastoVersion: Version?
|
||||||
var hometownVersion: Version?
|
var hometownVersion: Version?
|
||||||
let parts = ver.split(separator: "+")
|
|
||||||
if parts.count == 2,
|
|
||||||
let first = Version(string: String(parts[0])) {
|
|
||||||
if first > Version(1, 0, 8) {
|
|
||||||
// like 3.5.5+hometown-1.0.9
|
|
||||||
mastoVersion = first
|
|
||||||
if parts[1].starts(with: "hometown-") {
|
|
||||||
hometownVersion = Version(string: String(parts[1][parts[1].index(parts[1].startIndex, offsetBy: "hometown-".count + 1)...]))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// like "1.0.6+3.5.2"
|
// like "1.0.6+3.5.2"
|
||||||
hometownVersion = first
|
let parts = ver.split(separator: "+")
|
||||||
|
if parts.count == 2 {
|
||||||
mastoVersion = Version(string: String(parts[1]))
|
mastoVersion = Version(string: String(parts[1]))
|
||||||
}
|
hometownVersion = Version(string: String(parts[0]))
|
||||||
} else {
|
} else {
|
||||||
mastoVersion = Version(string: ver)
|
mastoVersion = Version(string: ver)
|
||||||
}
|
}
|
||||||
|
@ -138,7 +121,7 @@ struct InstanceFeatures {
|
||||||
maxStatusChars = instance.maxStatusCharacters ?? 500
|
maxStatusChars = instance.maxStatusCharacters ?? 500
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
|
func hasVersion(_ 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 {
|
||||||
|
|
|
@ -8,7 +8,6 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import Combine
|
|
||||||
|
|
||||||
class MastodonController: ObservableObject {
|
class MastodonController: ObservableObject {
|
||||||
|
|
||||||
|
@ -48,11 +47,7 @@ class MastodonController: ObservableObject {
|
||||||
@Published private(set) var nodeInfo: NodeInfo!
|
@Published private(set) var nodeInfo: NodeInfo!
|
||||||
@Published private(set) var instanceFeatures = InstanceFeatures()
|
@Published private(set) var instanceFeatures = InstanceFeatures()
|
||||||
@Published private(set) var lists: [List] = []
|
@Published private(set) var lists: [List] = []
|
||||||
@Published private(set) var customEmojis: [Emoji]?
|
private(set) var customEmojis: [Emoji]?
|
||||||
@Published private(set) var followedHashtags: [FollowedHashtag] = []
|
|
||||||
@Published private(set) var filters: [FilterMO] = []
|
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
private var pendingOwnInstanceRequestCallbacks = [(Result<Instance, Client.Error>) -> Void]()
|
private var pendingOwnInstanceRequestCallbacks = [(Result<Instance, Client.Error>) -> Void]()
|
||||||
private var ownInstanceRequest: URLSessionTask?
|
private var ownInstanceRequest: URLSessionTask?
|
||||||
|
@ -66,29 +61,6 @@ class MastodonController: ObservableObject {
|
||||||
self.accountInfo = nil
|
self.accountInfo = nil
|
||||||
self.client = Client(baseURL: instanceURL, session: .appDefault)
|
self.client = Client(baseURL: instanceURL, session: .appDefault)
|
||||||
self.transient = transient
|
self.transient = transient
|
||||||
|
|
||||||
$instance
|
|
||||||
.combineLatest($nodeInfo)
|
|
||||||
.compactMap { (instance, nodeInfo) in
|
|
||||||
if let instance {
|
|
||||||
return (instance, nodeInfo)
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sink { [unowned self] (instance, nodeInfo) in
|
|
||||||
self.instanceFeatures.update(instance: instance, nodeInfo: nodeInfo)
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
$instanceFeatures
|
|
||||||
.filter { [unowned self] in $0.canFollowHashtags && self.followedHashtags.isEmpty }
|
|
||||||
.sink { [unowned self] _ in
|
|
||||||
Task {
|
|
||||||
await self.loadFollowedHashtags()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
|
@ -148,25 +120,13 @@ class MastodonController: ObservableObject {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
func initialize() async throws {
|
||||||
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) {
|
||||||
|
@ -270,6 +230,7 @@ class MastodonController: ObservableObject {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.ownInstanceRequest = nil
|
self.ownInstanceRequest = nil
|
||||||
self.instance = instance
|
self.instance = instance
|
||||||
|
self.instanceFeatures.update(instance: instance, nodeInfo: self.nodeInfo)
|
||||||
|
|
||||||
for completion in self.pendingOwnInstanceRequestCallbacks {
|
for completion in self.pendingOwnInstanceRequestCallbacks {
|
||||||
completion(.success(instance))
|
completion(.success(instance))
|
||||||
|
@ -287,6 +248,9 @@ class MastodonController: ObservableObject {
|
||||||
case let .success(nodeInfo, _):
|
case let .success(nodeInfo, _):
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.nodeInfo = nodeInfo
|
self.nodeInfo = nodeInfo
|
||||||
|
if let instance = self.instance {
|
||||||
|
self.instanceFeatures.update(instance: instance, nodeInfo: nodeInfo)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -315,7 +279,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: SemiCaseSensitiveComparator.keyPath(\.title))
|
self.lists = lists.sorted(using: ListComparator())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -325,7 +289,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(using: SemiCaseSensitiveComparator.keyPath(\.title))
|
new.sort { $0.title < $1.title }
|
||||||
self.lists = new
|
self.lists = new
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -340,55 +304,23 @@ 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: SemiCaseSensitiveComparator.keyPath(\.title))
|
new.sort(using: ListComparator())
|
||||||
self.lists = new
|
self.lists = new
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func loadFollowedHashtags() async {
|
|
||||||
updateFollowedHashtags()
|
|
||||||
|
|
||||||
let req = Client.getFollowedHashtags()
|
|
||||||
if let (hashtags, _) = try? await run(req) {
|
|
||||||
self.persistentContainer.updateFollowedHashtags(hashtags) {
|
|
||||||
if case .success(let hashtags) = $0 {
|
|
||||||
self.followedHashtags = hashtags
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
private struct ListComparator: SortComparator {
|
||||||
func updateFollowedHashtags() {
|
typealias Compared = List
|
||||||
followedHashtags = (try? persistentContainer.viewContext.fetch(FollowedHashtag.fetchRequest())) ?? []
|
|
||||||
|
var underlying = String.Comparator(options: .caseInsensitive)
|
||||||
|
|
||||||
|
var order: SortOrder {
|
||||||
|
get { underlying.order }
|
||||||
|
set { underlying.order = newValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
func compare(_ lhs: List, _ rhs: List) -> ComparisonResult {
|
||||||
func loadFilters() async {
|
return underlying.compare(lhs.title, rhs.title)
|
||||||
var apiFilters: [AnyFilter]?
|
|
||||||
if instanceFeatures.filtersV2 {
|
|
||||||
let req = Client.getFiltersV2()
|
|
||||||
if let (filters, _) = try? await run(req) {
|
|
||||||
apiFilters = filters.map { .v2($0) }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let req = Client.getFiltersV1()
|
|
||||||
if let (filters, _) = try? await run(req) {
|
|
||||||
apiFilters = filters.map { .v1($0) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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())) ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,67 +0,0 @@
|
||||||
//
|
|
||||||
// ToggleFollowHashtagService.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 11/29/22.
|
|
||||||
// Copyright © 2022 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import Pachyderm
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class ToggleFollowHashtagService {
|
|
||||||
|
|
||||||
private let hashtag: Hashtag
|
|
||||||
private let mastodonController: MastodonController
|
|
||||||
private let presenter: any TuskerNavigationDelegate
|
|
||||||
|
|
||||||
init(hashtag: Hashtag, presenter: any TuskerNavigationDelegate) {
|
|
||||||
self.hashtag = hashtag
|
|
||||||
self.mastodonController = presenter.apiController
|
|
||||||
self.presenter = presenter
|
|
||||||
}
|
|
||||||
|
|
||||||
func toggleFollow() async {
|
|
||||||
let context = mastodonController.persistentContainer.viewContext
|
|
||||||
var config: ToastConfiguration
|
|
||||||
if let existing = mastodonController.followedHashtags.first(where: { $0.name == hashtag.name }) {
|
|
||||||
do {
|
|
||||||
let req = Hashtag.unfollow(name: hashtag.name)
|
|
||||||
_ = try await mastodonController.run(req)
|
|
||||||
|
|
||||||
context.delete(existing)
|
|
||||||
mastodonController.updateFollowedHashtags()
|
|
||||||
|
|
||||||
config = ToastConfiguration(title: "Unfollowed Hashtag")
|
|
||||||
config.systemImageName = "checkmark"
|
|
||||||
config.dismissAutomaticallyAfter = 2
|
|
||||||
} catch {
|
|
||||||
config = ToastConfiguration(from: error, with: "Error Unfollowing Hashtag", in: presenter) { [unowned self] toast in
|
|
||||||
toast.dismissToast(animated: true)
|
|
||||||
await self.toggleFollow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
do {
|
|
||||||
let req = Hashtag.follow(name: hashtag.name)
|
|
||||||
let (hashtag, _) = try await mastodonController.run(req)
|
|
||||||
|
|
||||||
_ = FollowedHashtag(hashtag: hashtag, context: context)
|
|
||||||
mastodonController.updateFollowedHashtags()
|
|
||||||
|
|
||||||
config = ToastConfiguration(title: "Followed Hashtag")
|
|
||||||
config.systemImageName = "checkmark"
|
|
||||||
config.dismissAutomaticallyAfter = 2
|
|
||||||
} catch {
|
|
||||||
config = ToastConfiguration(from: error, with: "Error Following Hashtag", in: presenter) { [unowned self] toast in
|
|
||||||
toast.dismissToast(animated: true)
|
|
||||||
await self.toggleFollow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
presenter.showToast(configuration: config, animated: true)
|
|
||||||
mastodonController.persistentContainer.save(context: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,52 +0,0 @@
|
||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -122,10 +122,6 @@ class DiskCache<T> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSizeInBytes() -> Int64? {
|
|
||||||
return fileManager.recursiveSize(url: URL(fileURLWithPath: path, isDirectory: true))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension DiskCache {
|
extension DiskCache {
|
||||||
|
|
|
@ -110,10 +110,6 @@ class ImageCache {
|
||||||
try cache.removeAll()
|
try cache.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDiskSizeInBytes() -> Int64? {
|
|
||||||
return cache.disk?.getSizeInBytes()
|
|
||||||
}
|
|
||||||
|
|
||||||
typealias Request = URLSessionDataTask
|
typealias Request = URLSessionDataTask
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ import UIKit
|
||||||
class ImageDataCache {
|
class ImageDataCache {
|
||||||
|
|
||||||
private let memory: MemoryCache<Entry>
|
private let memory: MemoryCache<Entry>
|
||||||
let disk: DiskCache<Data>?
|
private let disk: DiskCache<Data>?
|
||||||
|
|
||||||
private let storeOriginalDataInMemory: Bool
|
private let storeOriginalDataInMemory: Bool
|
||||||
private let desiredPixelSize: CGSize?
|
private let desiredPixelSize: CGSize?
|
||||||
|
|
|
@ -1,38 +0,0 @@
|
||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,140 +0,0 @@
|
||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,38 +0,0 @@
|
||||||
//
|
|
||||||
// FollowedHashtag.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 11/29/22.
|
|
||||||
// Copyright © 2022 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import CoreData
|
|
||||||
import Pachyderm
|
|
||||||
import WebURLFoundationExtras
|
|
||||||
|
|
||||||
@objc(FollowedHashtag)
|
|
||||||
public final class FollowedHashtag: NSManagedObject {
|
|
||||||
|
|
||||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<FollowedHashtag> {
|
|
||||||
return NSFetchRequest<FollowedHashtag>(entityName: "FollowedHashtag")
|
|
||||||
}
|
|
||||||
|
|
||||||
@nonobjc public class func fetchRequest(name: String) -> NSFetchRequest<FollowedHashtag> {
|
|
||||||
let req = NSFetchRequest<FollowedHashtag>(entityName: "FollowedHashtag")
|
|
||||||
req.predicate = NSPredicate(format: "name LIKE[cd] %@", name)
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
@NSManaged public var name: String
|
|
||||||
@NSManaged public var url: URL
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension FollowedHashtag {
|
|
||||||
convenience init(hashtag: Hashtag, context: NSManagedObjectContext) {
|
|
||||||
self.init(context: context)
|
|
||||||
self.name = hashtag.name
|
|
||||||
self.url = URL(hashtag.url)!
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -53,7 +53,7 @@ class MastodonCachePersistentStore: NSPersistentContainer {
|
||||||
storeDescription.type = NSInMemoryStoreType
|
storeDescription.type = NSInMemoryStoreType
|
||||||
persistentStoreDescriptions = [storeDescription]
|
persistentStoreDescriptions = [storeDescription]
|
||||||
} else {
|
} else {
|
||||||
super.init(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
|
super.init(name: "\(accountInfo!.id)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
loadPersistentStores { (description, error) in
|
loadPersistentStores { (description, error) in
|
||||||
|
@ -267,66 +267,6 @@ class MastodonCachePersistentStore: NSPersistentContainer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateFollowedHashtags(_ hashtags: [Hashtag], completion: @escaping (Result<[FollowedHashtag], Error>) -> Void) {
|
|
||||||
viewContext.perform {
|
|
||||||
do {
|
|
||||||
var all = try self.viewContext.fetch(FollowedHashtag.fetchRequest())
|
|
||||||
|
|
||||||
let toDelete = all.filter { existing in !hashtags.contains(where: { $0.name == existing.name }) }.map(\.objectID)
|
|
||||||
if !toDelete.isEmpty {
|
|
||||||
try self.viewContext.execute(NSBatchDeleteRequest(objectIDs: toDelete))
|
|
||||||
}
|
|
||||||
|
|
||||||
for hashtag in hashtags where !all.contains(where: { $0.name == hashtag.name}) {
|
|
||||||
let mo = FollowedHashtag(hashtag: hashtag, context: self.viewContext)
|
|
||||||
all.append(mo)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.save(context: self.viewContext)
|
|
||||||
completion(.success(all))
|
|
||||||
} catch {
|
|
||||||
completion(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasFollowedHashtag(_ hashtag: Hashtag) -> Bool {
|
|
||||||
do {
|
|
||||||
let req = FollowedHashtag.fetchRequest(name: name)
|
|
||||||
return try viewContext.count(for: req) > 0
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
||||||
|
|
|
@ -20,7 +20,7 @@ public final class SavedHashtag: NSManagedObject {
|
||||||
|
|
||||||
@nonobjc public class func fetchRequest(name: String) -> NSFetchRequest<SavedHashtag> {
|
@nonobjc public class func fetchRequest(name: String) -> NSFetchRequest<SavedHashtag> {
|
||||||
let req = NSFetchRequest<SavedHashtag>(entityName: "SavedHashtag")
|
let req = NSFetchRequest<SavedHashtag>(entityName: "SavedHashtag")
|
||||||
req.predicate = NSPredicate(format: "name LIKE[cd] %@", name)
|
req.predicate = NSPredicate(format: "name = %@", name)
|
||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -28,24 +28,6 @@
|
||||||
</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">
|
|
||||||
<attribute name="name" attributeType="String"/>
|
|
||||||
<attribute name="url" attributeType="URI"/>
|
|
||||||
</entity>
|
|
||||||
<entity name="Relationship" representedClassName="RelationshipMO" syncable="YES">
|
<entity name="Relationship" representedClassName="RelationshipMO" syncable="YES">
|
||||||
<attribute name="accountID" optional="YES" attributeType="String"/>
|
<attribute name="accountID" optional="YES" attributeType="String"/>
|
||||||
<attribute name="blocking" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
<attribute name="blocking" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
|
|
|
@ -1,35 +0,0 @@
|
||||||
//
|
|
||||||
// FileManager+Size.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 11/28/22.
|
|
||||||
// Copyright © 2022 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension FileManager {
|
|
||||||
func recursiveSize(url: URL) -> Int64? {
|
|
||||||
if (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true {
|
|
||||||
return size(url: url)
|
|
||||||
} else {
|
|
||||||
guard let enumerator = enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .totalFileAllocatedSizeKey]) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var total: Int64 = 0
|
|
||||||
for case let url as URL in enumerator {
|
|
||||||
total += size(url: url) ?? 0
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func size(url: URL) -> Int64? {
|
|
||||||
guard let resourceValues = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey, .totalFileAllocatedSizeKey]),
|
|
||||||
resourceValues.isRegularFile ?? false,
|
|
||||||
let size = resourceValues.fileSize ?? resourceValues.totalFileAllocatedSize else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return Int64(size)
|
|
||||||
}
|
|
|
@ -1,37 +0,0 @@
|
||||||
//
|
|
||||||
// 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,12 +9,12 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
extension CollapseState {
|
extension StatusState {
|
||||||
|
|
||||||
func resolveFor(status: StatusMO, height: CGFloat, textLength: Int? = nil) {
|
func resolveFor(status: StatusMO, height: CGFloat) {
|
||||||
let longEnoughToCollapse: Bool
|
let longEnoughToCollapse: Bool
|
||||||
if Preferences.shared.collapseLongPosts,
|
if Preferences.shared.collapseLongPosts,
|
||||||
height > 500 || (textLength != nil && textLength! > 500) {
|
height > 500 {
|
||||||
longEnoughToCollapse = true
|
longEnoughToCollapse = true
|
||||||
} else {
|
} else {
|
||||||
longEnoughToCollapse = false
|
longEnoughToCollapse = false
|
||||||
|
|
|
@ -1,208 +0,0 @@
|
||||||
//
|
|
||||||
// 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)?
|
|
||||||
|
|
||||||
var 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.expiresAt == nil || filter.expiresAt! > Date()) && 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, NSAttributedString?) {
|
|
||||||
switch state.state {
|
|
||||||
case .known(_, generation: let knownGen) where knownGen < generation:
|
|
||||||
fallthrough
|
|
||||||
case .unknown:
|
|
||||||
let (result, attributedString) = doResolve(status: status())
|
|
||||||
state.state = .known(result, generation: generation)
|
|
||||||
return (result, attributedString)
|
|
||||||
case .known(let result, _):
|
|
||||||
return (result, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setResult(_ result: Result, for state: FilterState) {
|
|
||||||
state.state = .known(result, generation: generation)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isKnownHide(state: FilterState) -> Bool {
|
|
||||||
switch state.state {
|
|
||||||
case .known(.hide, generation: let gen) where gen >= generation:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func doResolve(status: StatusMO) -> (Result, NSAttributedString?) {
|
|
||||||
if !hasSetup {
|
|
||||||
setupFilters(filters: mastodonController.filters)
|
|
||||||
}
|
|
||||||
if matchers.isEmpty {
|
|
||||||
return (.allow, nil)
|
|
||||||
}
|
|
||||||
@Lazy var text = self.htmlConverter.convert(status.content)
|
|
||||||
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.string, range: NSRange(location: 0, length: text.length)) > 0 {
|
|
||||||
return (result, _text.valueIfInitialized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (.allow, _text.valueIfInitialized)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,128 +0,0 @@
|
||||||
//
|
|
||||||
// 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))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,54 +0,0 @@
|
||||||
//
|
|
||||||
// Lazy.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 12/4/22.
|
|
||||||
// Copyright © 2022 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// A lazy initialization property wrapper that allows checking the initialization state.
|
|
||||||
@propertyWrapper
|
|
||||||
enum Lazy<Value> {
|
|
||||||
case uninitialized(() -> Value)
|
|
||||||
case initialized(Value)
|
|
||||||
|
|
||||||
init(wrappedValue: @autoclosure @escaping () -> Value) {
|
|
||||||
self = .uninitialized(wrappedValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the contained value, initializing it if the value hasn't been accessed before.
|
|
||||||
var wrappedValue: Value {
|
|
||||||
mutating get {
|
|
||||||
switch self {
|
|
||||||
case .uninitialized(let closure):
|
|
||||||
let value = closure()
|
|
||||||
self = .initialized(value)
|
|
||||||
return value
|
|
||||||
case .initialized(let value):
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether this Lazy has been initialized yet.
|
|
||||||
var isInitialized: Bool {
|
|
||||||
switch self {
|
|
||||||
case .uninitialized(_):
|
|
||||||
return false
|
|
||||||
case .initialized(_):
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// If this Lazy is initialized, this returns the value. Otherwise, it returns `nil`.
|
|
||||||
var valueIfInitialized: Value? {
|
|
||||||
switch self {
|
|
||||||
case .uninitialized(_):
|
|
||||||
return nil
|
|
||||||
case .initialized(let value):
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -208,12 +208,6 @@ extension LocalData {
|
||||||
self.accessToken = accessToken
|
self.accessToken = accessToken
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A filename-safe string for this account
|
|
||||||
var persistenceKey: String {
|
|
||||||
// slashes are not allowed in the persistent store coordinator name
|
|
||||||
id.replacingOccurrences(of: "/", with: "_")
|
|
||||||
}
|
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
func hash(into hasher: inout Hasher) {
|
||||||
hasher.combine(id)
|
hasher.combine(id)
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,6 @@ struct Logging {
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
static let general = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "General")
|
static let general = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "General")
|
||||||
static let generalSignposter = OSSignposter(logger: general)
|
|
||||||
|
|
||||||
static func getLogData() -> Data? {
|
static func getLogData() -> Data? {
|
||||||
do {
|
do {
|
||||||
|
|
|
@ -177,7 +177,6 @@ extension MastodonController {
|
||||||
var acctsToMention = [String]()
|
var acctsToMention = [String]()
|
||||||
|
|
||||||
var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility
|
var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility
|
||||||
var localOnly = false
|
|
||||||
var contentWarning = ""
|
var contentWarning = ""
|
||||||
|
|
||||||
if let inReplyToID = inReplyToID,
|
if let inReplyToID = inReplyToID,
|
||||||
|
@ -185,7 +184,6 @@ extension MastodonController {
|
||||||
acctsToMention.append(inReplyTo.account.acct)
|
acctsToMention.append(inReplyTo.account.acct)
|
||||||
acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct))
|
acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct))
|
||||||
visibility = min(visibility, inReplyTo.visibility)
|
visibility = min(visibility, inReplyTo.visibility)
|
||||||
localOnly = instanceFeatures.localOnlyPosts && inReplyTo.localOnly
|
|
||||||
|
|
||||||
if !inReplyTo.spoilerText.isEmpty {
|
if !inReplyTo.spoilerText.isEmpty {
|
||||||
switch Preferences.shared.contentWarningCopyMode {
|
switch Preferences.shared.contentWarningCopyMode {
|
||||||
|
@ -215,7 +213,6 @@ extension MastodonController {
|
||||||
draft.text = acctsToMention.map { "@\($0) " }.joined()
|
draft.text = acctsToMention.map { "@\($0) " }.joined()
|
||||||
draft.initialText = draft.text
|
draft.initialText = draft.text
|
||||||
draft.visibility = visibility
|
draft.visibility = visibility
|
||||||
draft.localOnly = localOnly
|
|
||||||
draft.contentWarning = contentWarning
|
draft.contentWarning = contentWarning
|
||||||
draft.contentWarningEnabled = !contentWarning.isEmpty
|
draft.contentWarningEnabled = !contentWarning.isEmpty
|
||||||
|
|
||||||
|
|
|
@ -1,69 +0,0 @@
|
||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -125,8 +125,6 @@ class Preferences: Codable, ObservableObject {
|
||||||
@Published var showIsStatusReplyIcon = false
|
@Published var showIsStatusReplyIcon = false
|
||||||
@Published var alwaysShowStatusVisibilityIcon = false
|
@Published var alwaysShowStatusVisibilityIcon = false
|
||||||
@Published var hideActionsInTimeline = false
|
@Published var hideActionsInTimeline = false
|
||||||
@Published var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog]
|
|
||||||
@Published var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
|
|
||||||
|
|
||||||
// MARK: Composing
|
// MARK: Composing
|
||||||
@Published var defaultPostVisibility = Status.Visibility.public
|
@Published var defaultPostVisibility = Status.Visibility.public
|
||||||
|
|
|
@ -1,172 +0,0 @@
|
||||||
//
|
|
||||||
// StatusSwipeAction.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 11/26/22.
|
|
||||||
// Copyright © 2022 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import Pachyderm
|
|
||||||
|
|
||||||
enum StatusSwipeAction: String, Codable, Hashable, CaseIterable {
|
|
||||||
case reply
|
|
||||||
case favorite
|
|
||||||
case reblog
|
|
||||||
case share
|
|
||||||
case bookmark
|
|
||||||
case openInSafari
|
|
||||||
|
|
||||||
var displayName: String {
|
|
||||||
switch self {
|
|
||||||
case .reply:
|
|
||||||
return "Reply"
|
|
||||||
case .favorite:
|
|
||||||
return "Favorite"
|
|
||||||
case .reblog:
|
|
||||||
return "Reblog"
|
|
||||||
case .share:
|
|
||||||
return "Share"
|
|
||||||
case .bookmark:
|
|
||||||
return "Bookmark"
|
|
||||||
case .openInSafari:
|
|
||||||
return "Open in Safari"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var systemImageName: String {
|
|
||||||
switch self {
|
|
||||||
case .reply:
|
|
||||||
return "arrowshape.turn.up.left.fill"
|
|
||||||
case .favorite:
|
|
||||||
return "star.fill"
|
|
||||||
case .reblog:
|
|
||||||
return "repeat"
|
|
||||||
case .share:
|
|
||||||
return "square.and.arrow.up"
|
|
||||||
case .bookmark:
|
|
||||||
return "bookmark.fill"
|
|
||||||
case .openInSafari:
|
|
||||||
return "safari"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func createAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? {
|
|
||||||
switch self {
|
|
||||||
case .reply:
|
|
||||||
return createReplyAction(status: status, container: container)
|
|
||||||
case .favorite:
|
|
||||||
return createFavoriteAction(status: status, container: container)
|
|
||||||
case .reblog:
|
|
||||||
return createReblogAction(status: status, container: container)
|
|
||||||
case .share:
|
|
||||||
return createShareAction(status: status, container: container)
|
|
||||||
case .bookmark:
|
|
||||||
return createBookmarkAction(status: status, container: container)
|
|
||||||
case .openInSafari:
|
|
||||||
return createOpenInSafariAction(status: status, container: container)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol StatusSwipeActionContainer: UIView {
|
|
||||||
var mastodonController: MastodonController! { get }
|
|
||||||
var navigationDelegate: any TuskerNavigationDelegate { get }
|
|
||||||
var toastableViewController: ToastableViewController? { get }
|
|
||||||
|
|
||||||
// necessary b/c the reblog-handling logic only exists in the cells
|
|
||||||
func performReplyAction()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createReplyAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? {
|
|
||||||
guard container.mastodonController.loggedIn else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let action = UIContextualAction(style: .normal, title: "Reply") { [unowned container] _, _, completion in
|
|
||||||
container.performReplyAction()
|
|
||||||
completion(true)
|
|
||||||
}
|
|
||||||
action.image = UIImage(systemName: "arrowshape.turn.up.left.fill")
|
|
||||||
action.backgroundColor = container.tintColor
|
|
||||||
return action
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createFavoriteAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? {
|
|
||||||
guard container.mastodonController.loggedIn else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let title = status.favourited ? "Unfavorite" : "Favorite"
|
|
||||||
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in
|
|
||||||
Task { @MainActor in
|
|
||||||
await FavoriteService(status: status, mastodonController: container.mastodonController, presenter: container.navigationDelegate).toggleFavorite()
|
|
||||||
completion(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
action.image = UIImage(systemName: "star.fill")
|
|
||||||
action.backgroundColor = status.favourited ? UIColor(displayP3Red: 235/255, green: 77/255, blue: 62/255, alpha: 1) : UIColor(displayP3Red: 1, green: 204/255, blue: 0, alpha: 1)
|
|
||||||
return action
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createReblogAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? {
|
|
||||||
guard container.mastodonController.loggedIn else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let title = status.reblogged ? "Unreblog" : "Reblog"
|
|
||||||
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in
|
|
||||||
Task { @MainActor in
|
|
||||||
await ReblogService(status: status, mastodonController: container.mastodonController, presenter: container.navigationDelegate).toggleReblog()
|
|
||||||
completion(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
action.image = UIImage(systemName: "repeat")
|
|
||||||
action.backgroundColor = status.reblogged ? UIColor(displayP3Red: 235/255, green: 77/255, blue: 62/255, alpha: 1) : container.tintColor
|
|
||||||
return action
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createShareAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction {
|
|
||||||
let action = UIContextualAction(style: .normal, title: "Share") { [unowned container] _, _, completion in
|
|
||||||
container.navigationDelegate.showMoreOptions(forStatus: status.id, source: .view(container))
|
|
||||||
completion(true)
|
|
||||||
}
|
|
||||||
// bold to more closesly match other action symbols
|
|
||||||
let config = UIImage.SymbolConfiguration(weight: .bold)
|
|
||||||
action.image = UIImage(systemName: "square.and.arrow.up")!.applyingSymbolConfiguration(config)!
|
|
||||||
action.backgroundColor = .lightGray
|
|
||||||
return action
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createBookmarkAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? {
|
|
||||||
guard container.mastodonController.loggedIn else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let bookmarked = status.bookmarked ?? false
|
|
||||||
let title = bookmarked ? "Unbookmark" : "Bookmark"
|
|
||||||
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in
|
|
||||||
Task { @MainActor in
|
|
||||||
let request = (bookmarked ? Status.unbookmark : Status.bookmark)(status.id)
|
|
||||||
do {
|
|
||||||
let (status, _) = try await container.mastodonController.run(request)
|
|
||||||
container.mastodonController.persistentContainer.addOrUpdate(status: status)
|
|
||||||
} catch {
|
|
||||||
if let toastable = container.toastableViewController {
|
|
||||||
let config = ToastConfiguration(from: error, with: "Error \(bookmarked ? "Unb" : "B")ookmarking", in: toastable, retryAction: nil)
|
|
||||||
toastable.showToast(configuration: config, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
completion(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
action.image = UIImage(systemName: "bookmark.fill")
|
|
||||||
action.backgroundColor = .systemRed
|
|
||||||
return action
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createOpenInSafariAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction {
|
|
||||||
let action = UIContextualAction(style: .normal, title: "Open in Safari") { [unowned container] _, _, completion in
|
|
||||||
container.navigationDelegate.selected(url: status.url!, allowUniversalLinks: false)
|
|
||||||
completion(true)
|
|
||||||
}
|
|
||||||
action.image = UIImage(systemName: "safari")
|
|
||||||
action.backgroundColor = container.tintColor
|
|
||||||
return action
|
|
||||||
}
|
|
|
@ -42,7 +42,9 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
|
|
||||||
let controller = MastodonController.getForAccount(account)
|
let controller = MastodonController.getForAccount(account)
|
||||||
session.mastodonController = controller
|
session.mastodonController = controller
|
||||||
controller.initialize()
|
Task {
|
||||||
|
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,7 +50,9 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
session.mastodonController = controller
|
session.mastodonController = controller
|
||||||
controller.initialize()
|
Task {
|
||||||
|
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,7 +212,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
|
|
||||||
func createAppUI() -> TuskerRootViewController {
|
func createAppUI() -> TuskerRootViewController {
|
||||||
let mastodonController = window!.windowScene!.session.mastodonController!
|
let mastodonController = window!.windowScene!.session.mastodonController!
|
||||||
mastodonController.initialize()
|
Task {
|
||||||
|
try? await mastodonController.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
let split = MainSplitViewController(mastodonController: mastodonController)
|
let split = MainSplitViewController(mastodonController: mastodonController)
|
||||||
if UIDevice.current.userInterfaceIdiom == .phone,
|
if UIDevice.current.userInterfaceIdiom == .phone,
|
||||||
|
|
|
@ -58,13 +58,6 @@ class AccountListViewController: UIViewController {
|
||||||
dataSource.apply(snapshot, animatingDifferences: false)
|
dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
|
||||||
super.viewWillAppear(animated)
|
|
||||||
|
|
||||||
collectionView.indexPathsForSelectedItems?.forEach {
|
|
||||||
collectionView.deselectItem(at: $0, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AccountListViewController {
|
extension AccountListViewController {
|
||||||
|
@ -88,7 +81,7 @@ extension AccountListViewController: UICollectionViewDelegate {
|
||||||
return UIContextMenuConfiguration {
|
return UIContextMenuConfiguration {
|
||||||
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
|
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
|
||||||
} actionProvider: { _ in
|
} actionProvider: { _ in
|
||||||
UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell)))
|
UIMenu(children: self.actionsForProfile(accountID: id, sourceView: cell))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -99,9 +99,11 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
|
||||||
return vc
|
return vc
|
||||||
case .gifv:
|
case .gifv:
|
||||||
// Passing the source view to the LargeImageGifvContentView is a crappy workaround for not
|
// Passing the source view to the LargeImageGifvContentView is a crappy workaround for not
|
||||||
// having the current frame to use as the animationImage. This will break when there
|
// having the video size directly inside the content view. This will break when there
|
||||||
// are more than 4 attachments and there is a gifv at index >= 3 (the More... button will show
|
// are more than 4 attachments and there is a gifv at index >= 3 (the More... button will show
|
||||||
// in place of the fourth attachment, so there aren't source views for the attachments at index >= 3).
|
// in place of the fourth attachment, so there aren't source views for the attachments at index >= 3).
|
||||||
|
// Really, what should happen is the LargeImageGifvContentView should get the size of the video from
|
||||||
|
// the AVFoundation instead of the source view.
|
||||||
// This isn't a priority as only Mastodon converts gifs to gifvs, and Mastodon (in its default configuration,
|
// This isn't a priority as only Mastodon converts gifs to gifvs, and Mastodon (in its default configuration,
|
||||||
// I don't know about forks) doesn't allow more than four attachments, meaning there will always be a source view.
|
// I don't know about forks) doesn't allow more than four attachments, meaning there will always be a source view.
|
||||||
let gifvContentView = LargeImageGifvContentView(attachment: attachment, source: sourceViews[index]!)
|
let gifvContentView = LargeImageGifvContentView(attachment: attachment, source: sourceViews[index]!)
|
||||||
|
|
|
@ -17,7 +17,7 @@ class BookmarksTableViewController: EnhancedTableViewController {
|
||||||
|
|
||||||
private var loaded = false
|
private var loaded = false
|
||||||
|
|
||||||
var statuses: [(id: String, state: CollapseState)] = []
|
var statuses: [(id: String, state: StatusState)] = []
|
||||||
|
|
||||||
var newer: RequestRange?
|
var newer: RequestRange?
|
||||||
var older: RequestRange?
|
var older: RequestRange?
|
||||||
|
|
|
@ -176,19 +176,6 @@ struct ComposeAutocompleteEmojisView: View {
|
||||||
@State private var emojis: [Emoji] = []
|
@State private var emojis: [Emoji] = []
|
||||||
@ScaledMetric private var emojiSize = 30
|
@ScaledMetric private var emojiSize = 30
|
||||||
|
|
||||||
private var emojisBySection: [String: [Emoji]] {
|
|
||||||
var values: [String: [Emoji]] = [:]
|
|
||||||
for emoji in emojis {
|
|
||||||
let key = emoji.category ?? ""
|
|
||||||
if !values.keys.contains(key) {
|
|
||||||
values[key] = [emoji]
|
|
||||||
} else {
|
|
||||||
values[key]!.append(emoji)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// When exapnded, the toggle button should be at the top. When collapsed, it should be centered.
|
// When exapnded, the toggle button should be at the top. When collapsed, it should be centered.
|
||||||
HStack(alignment: expanded ? .top : .center, spacing: 0) {
|
HStack(alignment: expanded ? .top : .center, spacing: 0) {
|
||||||
|
@ -227,9 +214,7 @@ struct ComposeAutocompleteEmojisView: View {
|
||||||
private var verticalGrid: some View {
|
private var verticalGrid: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) {
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) {
|
||||||
ForEach(emojisBySection.keys.sorted(), id: \.self) { section in
|
ForEach(emojis, id: \.shortcode) { (emoji) in
|
||||||
Section {
|
|
||||||
ForEach(emojisBySection[section]!, id: \.shortcode) { emoji in
|
|
||||||
Button {
|
Button {
|
||||||
uiState.currentInput?.autocomplete(with: ":\(emoji.shortcode):")
|
uiState.currentInput?.autocomplete(with: ":\(emoji.shortcode):")
|
||||||
} label: {
|
} label: {
|
||||||
|
@ -237,20 +222,6 @@ struct ComposeAutocompleteEmojisView: View {
|
||||||
.frame(height: emojiSize)
|
.frame(height: emojiSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
|
||||||
if !section.isEmpty {
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(section)
|
|
||||||
.font(.caption)
|
|
||||||
|
|
||||||
Rectangle()
|
|
||||||
.foregroundColor(Color(.separator))
|
|
||||||
.frame(height: 0.5)
|
|
||||||
}
|
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.all, 8)
|
.padding(.all, 8)
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,7 +22,7 @@ struct ComposeCurrentAccount: View {
|
||||||
ComposeAvatarImageView(url: account?.avatar)
|
ComposeAvatarImageView(url: account?.avatar)
|
||||||
.frame(width: 50, height: 50)
|
.frame(width: 50, height: 50)
|
||||||
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
|
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
|
||||||
.accessibilityHidden(true)
|
.accessibility(label: Text(account != nil ? "\(account!.displayName) avatar" : "Avatar"))
|
||||||
|
|
||||||
if let id = account?.id,
|
if let id = account?.id,
|
||||||
let account = mastodonController.persistentContainer.account(for: id) {
|
let account = mastodonController.persistentContainer.account(for: id) {
|
||||||
|
|
|
@ -43,10 +43,10 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Wra
|
||||||
super.init(rootView: wrapper)
|
super.init(rootView: wrapper)
|
||||||
|
|
||||||
self.uiState.delegate = self
|
self.uiState.delegate = self
|
||||||
pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self)
|
|
||||||
userActivity = UserActivityManager.newPostActivity(accountID: mastodonController.accountInfo!.id)
|
|
||||||
|
|
||||||
updateNavigationTitle(draft: uiState.draft)
|
pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self)
|
||||||
|
|
||||||
|
userActivity = UserActivityManager.newPostActivity(accountID: mastodonController.accountInfo!.id)
|
||||||
|
|
||||||
self.uiState.$draft
|
self.uiState.$draft
|
||||||
.flatMap(\.objectWillChange)
|
.flatMap(\.objectWillChange)
|
||||||
|
@ -55,27 +55,12 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Wra
|
||||||
DraftsManager.save()
|
DraftsManager.save()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
self.uiState.$draft
|
|
||||||
.sink { [unowned self] draft in
|
|
||||||
self.updateNavigationTitle(draft: draft)
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder aDecoder: NSCoder) {
|
required init?(coder aDecoder: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateNavigationTitle(draft: Draft) {
|
|
||||||
if let id = draft.inReplyToID,
|
|
||||||
let status = mastodonController.persistentContainer.status(for: id) {
|
|
||||||
navigationItem.title = "Reply to @\(status.account.acct)"
|
|
||||||
} else {
|
|
||||||
navigationItem.title = "New Post"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
|
||||||
|
@ -107,11 +92,6 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Wra
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func accessibilityPerformEscape() -> Bool {
|
|
||||||
dismissCompose(mode: .cancel)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Duckable
|
// MARK: Duckable
|
||||||
|
|
||||||
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {
|
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {
|
||||||
|
|
|
@ -80,7 +80,6 @@ struct ComposeReplyView: View {
|
||||||
.frame(width: 50, height: 50)
|
.frame(width: 50, height: 50)
|
||||||
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
|
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
|
||||||
.offset(x: 0, y: offset)
|
.offset(x: 0, y: offset)
|
||||||
.accessibilityHidden(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -107,6 +107,7 @@ struct ComposeView: View {
|
||||||
globalFrameOutsideList = frame
|
globalFrameOutsideList = frame
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.navigationTitle(navTitle)
|
||||||
.sheet(isPresented: $uiState.isShowingDraftsList) {
|
.sheet(isPresented: $uiState.isShowingDraftsList) {
|
||||||
DraftsView(currentDraft: draft, mastodonController: mastodonController)
|
DraftsView(currentDraft: draft, mastodonController: mastodonController)
|
||||||
}
|
}
|
||||||
|
@ -202,19 +203,23 @@ struct ComposeView: View {
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
ComposeCurrentAccount()
|
ComposeCurrentAccount()
|
||||||
.accessibilitySortPriority(1)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Text(verbatim: charactersRemaining.description)
|
Text(verbatim: charactersRemaining.description)
|
||||||
.foregroundColor(charactersRemaining < 0 ? .red : .secondary)
|
.foregroundColor(charactersRemaining < 0 ? .red : .secondary)
|
||||||
.font(Font.body.monospacedDigit())
|
.font(Font.body.monospacedDigit())
|
||||||
.accessibility(label: Text(charactersRemaining < 0 ? "\(-charactersRemaining) characters too many" : "\(charactersRemaining) characters remaining"))
|
.accessibility(label: Text(charactersRemaining < 0 ? "\(-charactersRemaining) characters too many" : "\(charactersRemaining) characters remaining"))
|
||||||
// this should come first, so VO users can back to it from the main compose text view
|
|
||||||
.accessibilitySortPriority(0)
|
|
||||||
}.frame(height: 50)
|
}.frame(height: 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var navTitle: Text {
|
||||||
|
if let id = draft.inReplyToID,
|
||||||
|
let status = mastodonController.persistentContainer.status(for: id) {
|
||||||
|
return Text("Reply to @\(status.account.acct)")
|
||||||
|
} else {
|
||||||
|
return Text("New Post")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var cancelButton: some View {
|
private var cancelButton: some View {
|
||||||
Button(action: self.cancel) {
|
Button(action: self.cancel) {
|
||||||
Text("Cancel")
|
Text("Cancel")
|
||||||
|
@ -231,7 +236,6 @@ struct ComposeView: View {
|
||||||
} label: {
|
} label: {
|
||||||
Text("Post")
|
Text("Post")
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.return, modifiers: .command)
|
|
||||||
.disabled(!postButtonEnabled)
|
.disabled(!postButtonEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -30,7 +30,7 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
weak var mastodonController: MastodonController!
|
weak var mastodonController: MastodonController!
|
||||||
|
|
||||||
let mainStatusID: String
|
let mainStatusID: String
|
||||||
let mainStatusState: CollapseState
|
let mainStatusState: StatusState
|
||||||
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: CollapseState = .unknown, mastodonController: MastodonController) {
|
init(for mainStatusID: String, state: StatusState = .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: CollapseState)? {
|
func item(for indexPath: IndexPath) -> (id: String, state: StatusState)? {
|
||||||
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: CollapseState)
|
case status(id: String, state: StatusState)
|
||||||
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: CollapseState) -> ConversationTableViewController {
|
func conversation(mainStatusID: String, state: StatusState) -> 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
|
||||||
|
|
|
@ -174,7 +174,7 @@ extension ProfileDirectoryViewController: UICollectionViewDelegate {
|
||||||
return UIContextMenuConfiguration(identifier: nil) {
|
return UIContextMenuConfiguration(identifier: nil) {
|
||||||
return ProfileViewController(accountID: account.id, mastodonController: self.mastodonController)
|
return ProfileViewController(accountID: account.id, mastodonController: self.mastodonController)
|
||||||
} actionProvider: { (_) in
|
} actionProvider: { (_) in
|
||||||
let actions = self.actionsForProfile(accountID: account.id, source: .view(self.collectionView.cellForItem(at: indexPath)))
|
let actions = self.actionsForProfile(accountID: account.id, sourceView: self.collectionView.cellForItem(at: indexPath))
|
||||||
return UIMenu(children: actions)
|
return UIMenu(children: actions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -98,7 +98,7 @@ extension TrendingHashtagsViewController: UICollectionViewDelegate {
|
||||||
return UIContextMenuConfiguration(identifier: nil) {
|
return UIContextMenuConfiguration(identifier: nil) {
|
||||||
HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController)
|
HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController)
|
||||||
} actionProvider: { (_) in
|
} actionProvider: { (_) in
|
||||||
UIMenu(children: self.actionsForHashtag(hashtag, source: .view(self.collectionView.cellForItem(at: indexPath))))
|
UIMenu(children: self.actionsForHashtag(hashtag, sourceView: self.collectionView.cellForItem(at: indexPath)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -62,10 +62,9 @@ class TrendingStatusesViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||||
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, CollapseState)> { [unowned self] cell, indexPath, item in
|
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState)> { [unowned self] cell, indexPath, item in
|
||||||
cell.delegate = self
|
cell.delegate = self
|
||||||
// TODO: filter these
|
cell.updateUI(statusID: item.0, state: item.1)
|
||||||
cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow, precomputedContent: nil)
|
|
||||||
}
|
}
|
||||||
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, _, _ in
|
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, _, _ in
|
||||||
cell.indicator.startAnimating()
|
cell.indicator.startAnimating()
|
||||||
|
@ -120,7 +119,7 @@ extension TrendingStatusesViewController {
|
||||||
case statuses
|
case statuses
|
||||||
}
|
}
|
||||||
enum Item: Hashable {
|
enum Item: Hashable {
|
||||||
case status(id: String, state: CollapseState)
|
case status(id: String, state: StatusState)
|
||||||
case loadingIndicator
|
case loadingIndicator
|
||||||
|
|
||||||
static func ==(lhs: Item, rhs: Item) -> Bool {
|
static func ==(lhs: Item, rhs: Item) -> Bool {
|
||||||
|
@ -207,10 +206,6 @@ 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 {
|
||||||
|
|
|
@ -1,214 +0,0 @@
|
||||||
//
|
|
||||||
// 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()
|
|
||||||
// }
|
|
||||||
//}
|
|
|
@ -1,63 +0,0 @@
|
||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,129 +0,0 @@
|
||||||
//
|
|
||||||
// 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()
|
|
||||||
// }
|
|
||||||
//}
|
|
|
@ -146,9 +146,11 @@ class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView {
|
||||||
|
|
||||||
private let asset: AVURLAsset
|
private let asset: AVURLAsset
|
||||||
|
|
||||||
private var videoSize: CGSize?
|
// The content view needs to supply an intrinsicContentSize for the LargeImageViewController to handle layout/scrolling/zooming correctly
|
||||||
override var intrinsicContentSize: CGSize {
|
override var intrinsicContentSize: CGSize {
|
||||||
videoSize ?? CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
|
// This is a really sucky workaround for the fact that in the content view, we don't have access to the size of the underlying video.
|
||||||
|
// There's probably some way of getting this from the AVPlayer/AVAsset directly
|
||||||
|
animationImage?.size ?? CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(attachment: Attachment, source: UIImageView) {
|
init(attachment: Attachment, source: UIImageView) {
|
||||||
|
@ -161,17 +163,6 @@ class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView {
|
||||||
self.animationImage = source.image
|
self.animationImage = source.image
|
||||||
|
|
||||||
self.player.play()
|
self.player.play()
|
||||||
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
if let track = try await asset.loadTracks(withMediaType: .video).first {
|
|
||||||
let (size, transform) = try await track.load(.naturalSize, .preferredTransform)
|
|
||||||
self.videoSize = size.applying(transform)
|
|
||||||
self.invalidateIntrinsicContentSize()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
|
|
|
@ -48,7 +48,6 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
||||||
|
|
||||||
private var prevZoomScale: CGFloat?
|
private var prevZoomScale: CGFloat?
|
||||||
private var isGrayscale = false
|
private var isGrayscale = false
|
||||||
private var contentViewSizeObservation: NSKeyValueObservation?
|
|
||||||
|
|
||||||
var isInteractivelyAnimatingDismissal: Bool = false {
|
var isInteractivelyAnimatingDismissal: Bool = false {
|
||||||
didSet {
|
didSet {
|
||||||
|
@ -128,9 +127,6 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
||||||
contentViewLeadingConstraint,
|
contentViewLeadingConstraint,
|
||||||
contentViewTopConstraint,
|
contentViewTopConstraint,
|
||||||
])
|
])
|
||||||
contentViewSizeObservation = (contentView as UIView).observe(\.bounds, changeHandler: { [unowned self] _, _ in
|
|
||||||
self.centerImage()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupControls() {
|
private func setupControls() {
|
||||||
|
@ -263,6 +259,8 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
||||||
}
|
}
|
||||||
|
|
||||||
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||||
|
centerImage()
|
||||||
|
|
||||||
let prevZoomScale = self.prevZoomScale ?? scrollView.minimumZoomScale
|
let prevZoomScale = self.prevZoomScale ?? scrollView.minimumZoomScale
|
||||||
if scrollView.zoomScale <= scrollView.minimumZoomScale {
|
if scrollView.zoomScale <= scrollView.minimumZoomScale {
|
||||||
setControlsVisible(true, animated: true)
|
setControlsVisible(true, animated: true)
|
||||||
|
|
|
@ -20,7 +20,7 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
||||||
|
|
||||||
var nextRange: RequestRange?
|
var nextRange: RequestRange?
|
||||||
|
|
||||||
var searchResultsController: SearchResultsViewController!
|
var searchResultsController: EditListSearchResultsContainerViewController!
|
||||||
var searchController: UISearchController!
|
var searchController: UISearchController!
|
||||||
|
|
||||||
private var listRenamedCancellable: AnyCancellable?
|
private var listRenamedCancellable: AnyCancellable?
|
||||||
|
@ -64,22 +64,27 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
||||||
})
|
})
|
||||||
dataSource.editListAccountsController = self
|
dataSource.editListAccountsController = self
|
||||||
|
|
||||||
searchResultsController = SearchResultsViewController(mastodonController: mastodonController, resultTypes: [.accounts])
|
searchResultsController = EditListSearchResultsContainerViewController(mastodonController: mastodonController) { [unowned self] accountID in
|
||||||
searchResultsController.following = true
|
Task {
|
||||||
searchResultsController.delegate = self
|
await self.addAccount(id: accountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
searchController = UISearchController(searchResultsController: searchResultsController)
|
searchController = UISearchController(searchResultsController: searchResultsController)
|
||||||
searchController.hidesNavigationBarDuringPresentation = false
|
searchController.hidesNavigationBarDuringPresentation = false
|
||||||
searchController.searchResultsUpdater = searchResultsController
|
searchController.searchResultsUpdater = searchResultsController
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
searchController.scopeBarActivation = .onSearchActivation
|
||||||
|
} else {
|
||||||
|
searchController.automaticallyShowsScopeBar = true
|
||||||
|
}
|
||||||
searchController.searchBar.autocapitalizationType = .none
|
searchController.searchBar.autocapitalizationType = .none
|
||||||
searchController.searchBar.placeholder = NSLocalizedString("Search accounts you follow", comment: "edit list search field placeholder")
|
searchController.searchBar.placeholder = NSLocalizedString("Search for accounts to add", comment: "edit list search field placeholder")
|
||||||
searchController.searchBar.delegate = searchResultsController
|
searchController.searchBar.delegate = searchResultsController
|
||||||
|
searchController.searchBar.scopeButtonTitles = ["Everyone", "People You Follow"]
|
||||||
definesPresentationContext = true
|
definesPresentationContext = true
|
||||||
|
|
||||||
navigationItem.searchController = searchController
|
navigationItem.searchController = searchController
|
||||||
navigationItem.hidesSearchBarWhenScrolling = false
|
navigationItem.hidesSearchBarWhenScrolling = false
|
||||||
if #available(iOS 16.0, *) {
|
|
||||||
navigationItem.preferredSearchBarPlacement = .stacked
|
|
||||||
}
|
|
||||||
|
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Rename", comment: "rename list button title"), style: .plain, target: self, action: #selector(renameButtonPressed))
|
navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Rename", comment: "rename list button title"), style: .plain, target: self, action: #selector(renameButtonPressed))
|
||||||
|
|
||||||
|
@ -201,11 +206,3 @@ extension EditListAccountsViewController: ToastableViewController {
|
||||||
|
|
||||||
extension EditListAccountsViewController: MenuActionProvider {
|
extension EditListAccountsViewController: MenuActionProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension EditListAccountsViewController: SearchResultsViewControllerDelegate {
|
|
||||||
func selectedSearchResult(account accountID: String) {
|
|
||||||
Task {
|
|
||||||
await addAccount(id: accountID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,178 @@
|
||||||
|
//
|
||||||
|
// EditListSearchFollowingViewController.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/11/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
class EditListSearchFollowingViewController: EnhancedTableViewController {
|
||||||
|
|
||||||
|
private let mastodonController: MastodonController
|
||||||
|
private let didSelectAccount: (String) -> Void
|
||||||
|
|
||||||
|
private var dataSource: UITableViewDiffableDataSource<Section, String>!
|
||||||
|
|
||||||
|
private var query: String?
|
||||||
|
private var accountIDs: [String] = []
|
||||||
|
private var nextRange: RequestRange?
|
||||||
|
|
||||||
|
init(mastodonController: MastodonController, didSelectAccount: @escaping (String) -> Void) {
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
self.didSelectAccount = didSelectAccount
|
||||||
|
|
||||||
|
super.init(style: .grouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: "accountCell")
|
||||||
|
dataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, itemIdentifier in
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "accountCell", for: indexPath) as! AccountTableViewCell
|
||||||
|
cell.delegate = self
|
||||||
|
cell.updateUI(accountID: itemIdentifier)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
|
if dataSource.snapshot().numberOfItems == 0 {
|
||||||
|
Task {
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
|
print("will display: \(indexPath)")
|
||||||
|
if indexPath.row == tableView.numberOfRows(inSection: indexPath.section) - 1 {
|
||||||
|
Task {
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
guard let id = dataSource.itemIdentifier(for: indexPath) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
didSelectAccount(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
do {
|
||||||
|
let ownAccount = try await mastodonController.getOwnAccount()
|
||||||
|
let req = Account.getFollowing(ownAccount.id, range: nextRange ?? .default)
|
||||||
|
let (following, pagination) = try await mastodonController.run(req)
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
mastodonController.persistentContainer.addAll(accounts: following) {
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accountIDs.append(contentsOf: following.lazy.map(\.id))
|
||||||
|
nextRange = pagination?.older
|
||||||
|
updateDataSource(appending: following.map(\.id))
|
||||||
|
} catch {
|
||||||
|
let config = ToastConfiguration(from: error, with: "Error Loading Following", in: self) { toast in
|
||||||
|
toast.dismissToast(animated: true)
|
||||||
|
await self.load()
|
||||||
|
}
|
||||||
|
self.showToast(configuration: config, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateDataSourceForQueryChanged() {
|
||||||
|
guard let query, !query.isEmpty else {
|
||||||
|
let snapshot = NSDiffableDataSourceSnapshot<Section, String>()
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let ids = filterAccounts(ids: accountIDs, with: query)
|
||||||
|
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
if snapshot.indexOfSection(.accounts) == nil {
|
||||||
|
snapshot.appendSections([.accounts])
|
||||||
|
} else {
|
||||||
|
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .accounts))
|
||||||
|
}
|
||||||
|
snapshot.appendItems(ids)
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
|
||||||
|
// if there aren't any results for the current query, try to load more
|
||||||
|
if ids.isEmpty {
|
||||||
|
Task {
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateDataSource(appending ids: [String]) {
|
||||||
|
guard let query, !query.isEmpty else {
|
||||||
|
let snapshot = NSDiffableDataSourceSnapshot<Section, String>()
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let ids = filterAccounts(ids: ids, with: query)
|
||||||
|
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
if snapshot.indexOfSection(.accounts) == nil {
|
||||||
|
snapshot.appendSections([.accounts])
|
||||||
|
}
|
||||||
|
let existing = snapshot.itemIdentifiers(inSection: .accounts)
|
||||||
|
snapshot.appendItems(ids.filter { !existing.contains($0) })
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
|
||||||
|
// if there aren't any results for the current query, try to load more
|
||||||
|
if ids.isEmpty {
|
||||||
|
Task {
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func filterAccounts(ids: [String], with query: String) -> [String] {
|
||||||
|
let req = AccountMO.fetchRequest()
|
||||||
|
req.predicate = NSPredicate(format: "id in %@", ids)
|
||||||
|
let accounts = try! mastodonController.persistentContainer.viewContext.fetch(req)
|
||||||
|
|
||||||
|
return accounts
|
||||||
|
.map { (account) -> (AccountMO, Bool) in
|
||||||
|
let displayNameMatch = FuzzyMatcher.match(pattern: query, str: account.displayNameWithoutCustomEmoji)
|
||||||
|
let usernameMatch = FuzzyMatcher.match(pattern: query, str: account.acct)
|
||||||
|
return (account, displayNameMatch.matched || usernameMatch.matched)
|
||||||
|
}
|
||||||
|
.filter(\.1)
|
||||||
|
.map(\.0.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateQuery(_ query: String) {
|
||||||
|
self.query = query
|
||||||
|
updateDataSourceForQueryChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EditListSearchFollowingViewController {
|
||||||
|
enum Section {
|
||||||
|
case accounts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EditListSearchFollowingViewController: TuskerNavigationDelegate {
|
||||||
|
var apiController: MastodonController! { mastodonController }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EditListSearchFollowingViewController: MenuActionProvider {
|
||||||
|
}
|
|
@ -0,0 +1,115 @@
|
||||||
|
//
|
||||||
|
// EditListSearchResultsContainerViewController.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/11/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
class EditListSearchResultsContainerViewController: UIViewController {
|
||||||
|
|
||||||
|
private let mastodonController: MastodonController
|
||||||
|
private let didSelectAccount: (String) -> Void
|
||||||
|
|
||||||
|
private let searchResultsController: SearchResultsViewController
|
||||||
|
private let searchFollowingController: EditListSearchFollowingViewController
|
||||||
|
|
||||||
|
var mode = Mode.search {
|
||||||
|
willSet {
|
||||||
|
currentViewController.removeViewAndController()
|
||||||
|
}
|
||||||
|
didSet {
|
||||||
|
embedChild(currentViewController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var currentViewController: UIViewController {
|
||||||
|
switch mode {
|
||||||
|
case .search:
|
||||||
|
return searchResultsController
|
||||||
|
case .following:
|
||||||
|
return searchFollowingController
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentQuery: String?
|
||||||
|
private var searchSubject = PassthroughSubject<String?, Never>()
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(mastodonController: MastodonController, didSelectAccount: @escaping (String) -> Void) {
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
self.didSelectAccount = didSelectAccount
|
||||||
|
|
||||||
|
self.searchResultsController = SearchResultsViewController(mastodonController: mastodonController, resultTypes: [.accounts])
|
||||||
|
self.searchFollowingController = EditListSearchFollowingViewController(mastodonController: mastodonController, didSelectAccount: didSelectAccount)
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
self.searchResultsController.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
embedChild(currentViewController)
|
||||||
|
|
||||||
|
searchSubject
|
||||||
|
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
|
||||||
|
.sink { [unowned self] in self.performSearch(query: $0) }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func performSearch(query: String?) {
|
||||||
|
guard var query = query?.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if query.starts(with: "@") {
|
||||||
|
query = String(query.dropFirst())
|
||||||
|
}
|
||||||
|
guard query != self.currentQuery else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.currentQuery = query
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case .search:
|
||||||
|
searchResultsController.performSearch(query: query)
|
||||||
|
case .following:
|
||||||
|
searchFollowingController.updateQuery(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Mode: Equatable {
|
||||||
|
case search, following
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EditListSearchResultsContainerViewController: UISearchResultsUpdating {
|
||||||
|
func updateSearchResults(for searchController: UISearchController) {
|
||||||
|
searchSubject.send(searchController.searchBar.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EditListSearchResultsContainerViewController: UISearchBarDelegate {
|
||||||
|
func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
|
||||||
|
performSearch(query: searchBar.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
|
||||||
|
mode = selectedScope == 0 ? .search : .following
|
||||||
|
performSearch(query: searchBar.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EditListSearchResultsContainerViewController: SearchResultsViewControllerDelegate {
|
||||||
|
func selectedSearchResult(account accountID: String) {
|
||||||
|
didSelectAccount(accountID)
|
||||||
|
}
|
||||||
|
}
|
|
@ -48,7 +48,7 @@ class ListTimelineViewController: TimelineViewController {
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
|
|
||||||
if presentEditOnAppear {
|
if presentEditOnAppear {
|
||||||
presentEdit(animated: true)
|
presentEdit(animated: animated)
|
||||||
presentEditOnAppear = false
|
presentEditOnAppear = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,21 +12,10 @@ import Duckable
|
||||||
@available(iOS 16.0, *)
|
@available(iOS 16.0, *)
|
||||||
extension DuckableContainerViewController: TuskerRootViewController {
|
extension DuckableContainerViewController: TuskerRootViewController {
|
||||||
func stateRestorationActivity() -> NSUserActivity? {
|
func stateRestorationActivity() -> NSUserActivity? {
|
||||||
var activity = (child as? TuskerRootViewController)?.stateRestorationActivity()
|
(child as? TuskerRootViewController)?.stateRestorationActivity()
|
||||||
if let compose = duckedViewController as? ComposeHostingController,
|
|
||||||
compose.draft.hasContent {
|
|
||||||
activity = UserActivityManager.addDuckedDraft(to: activity, draft: compose.draft)
|
|
||||||
}
|
|
||||||
return activity
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreActivity(_ activity: NSUserActivity) {
|
func restoreActivity(_ activity: NSUserActivity) {
|
||||||
if let draft = UserActivityManager.getDraft(from: activity),
|
|
||||||
let account = UserActivityManager.getAccount(from: activity) {
|
|
||||||
let mastodonController = MastodonController.getForAccount(account)
|
|
||||||
let compose = ComposeHostingController(draft: draft, mastodonController: mastodonController)
|
|
||||||
_ = presentDuckable(compose, animated: false, isDucked: true)
|
|
||||||
}
|
|
||||||
(child as? TuskerRootViewController)?.restoreActivity(activity)
|
(child as? TuskerRootViewController)?.restoreActivity(activity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,6 @@ import Combine
|
||||||
protocol MainSidebarViewControllerDelegate: AnyObject {
|
protocol MainSidebarViewControllerDelegate: AnyObject {
|
||||||
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
|
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
|
||||||
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item)
|
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item)
|
||||||
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class MainSidebarViewController: UIViewController {
|
class MainSidebarViewController: UIViewController {
|
||||||
|
@ -209,18 +208,28 @@ class MainSidebarViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reloadLists(_ lists: [List]) {
|
private func reloadLists(_ lists: [List]) {
|
||||||
if let selectedItem,
|
|
||||||
case .list(let list) = selectedItem,
|
|
||||||
!lists.contains(where: { $0.id == list.id }) {
|
|
||||||
returnToPreviousItem()
|
|
||||||
}
|
|
||||||
|
|
||||||
var exploreSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
|
var exploreSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
|
||||||
exploreSnapshot.append([.listsHeader])
|
exploreSnapshot.append([.listsHeader])
|
||||||
exploreSnapshot.expand([.listsHeader])
|
exploreSnapshot.expand([.listsHeader])
|
||||||
exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader)
|
exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader)
|
||||||
exploreSnapshot.append([.addList], to: .listsHeader)
|
exploreSnapshot.append([.addList], to: .listsHeader)
|
||||||
self.dataSource.apply(exploreSnapshot, to: .lists)
|
var selectedItem: Item?
|
||||||
|
if let selectedIndexPath = collectionView.indexPathsForSelectedItems?.first,
|
||||||
|
let item = dataSource.itemIdentifier(for: selectedIndexPath) {
|
||||||
|
if case .list(let list) = item,
|
||||||
|
let newList = lists.first(where: { $0.id == list.id }) {
|
||||||
|
selectedItem = .list(newList)
|
||||||
|
} else {
|
||||||
|
selectedItem = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.dataSource.apply(exploreSnapshot, to: .lists) {
|
||||||
|
if let selectedItem,
|
||||||
|
let indexPath = self.dataSource.indexPath(for: selectedItem) {
|
||||||
|
self.collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredVertically)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
@ -246,39 +255,39 @@ class MainSidebarViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func reloadSavedHashtags() {
|
@objc private func reloadSavedHashtags() {
|
||||||
let hashtags = fetchSavedHashtags().map {
|
let selected = collectionView.indexPathsForSelectedItems?.first
|
||||||
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
|
|
||||||
}
|
|
||||||
if let selectedItem,
|
|
||||||
case .savedHashtag(_) = selectedItem,
|
|
||||||
!hashtags.contains(selectedItem) {
|
|
||||||
returnToPreviousItem()
|
|
||||||
}
|
|
||||||
|
|
||||||
var hashtagsSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
|
var hashtagsSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
|
||||||
hashtagsSnapshot.append([.savedHashtagsHeader])
|
hashtagsSnapshot.append([.savedHashtagsHeader])
|
||||||
hashtagsSnapshot.expand([.savedHashtagsHeader])
|
hashtagsSnapshot.expand([.savedHashtagsHeader])
|
||||||
|
let hashtags = fetchSavedHashtags().map {
|
||||||
|
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
|
||||||
|
}
|
||||||
hashtagsSnapshot.append(hashtags, to: .savedHashtagsHeader)
|
hashtagsSnapshot.append(hashtags, to: .savedHashtagsHeader)
|
||||||
hashtagsSnapshot.append([.addSavedHashtag], to: .savedHashtagsHeader)
|
hashtagsSnapshot.append([.addSavedHashtag], to: .savedHashtagsHeader)
|
||||||
self.dataSource.apply(hashtagsSnapshot, to: .savedHashtags)
|
self.dataSource.apply(hashtagsSnapshot, to: .savedHashtags, animatingDifferences: false) {
|
||||||
|
if let selected = selected {
|
||||||
|
self.collectionView.selectItem(at: selected, animated: false, scrollPosition: .centeredVertically)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func reloadSavedInstances() {
|
@objc private func reloadSavedInstances() {
|
||||||
let instances = fetchSavedInstances().map {
|
let selected = collectionView.indexPathsForSelectedItems?.first
|
||||||
Item.savedInstance($0.url)
|
|
||||||
}
|
|
||||||
if let selectedItem,
|
|
||||||
case .savedInstance(_) = selectedItem,
|
|
||||||
!instances.contains(selectedItem) {
|
|
||||||
returnToPreviousItem()
|
|
||||||
}
|
|
||||||
|
|
||||||
var instancesSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
|
var instancesSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
|
||||||
instancesSnapshot.append([.savedInstancesHeader])
|
instancesSnapshot.append([.savedInstancesHeader])
|
||||||
instancesSnapshot.expand([.savedInstancesHeader])
|
instancesSnapshot.expand([.savedInstancesHeader])
|
||||||
|
let instances = fetchSavedInstances().map {
|
||||||
|
Item.savedInstance($0.url)
|
||||||
|
}
|
||||||
instancesSnapshot.append(instances, to: .savedInstancesHeader)
|
instancesSnapshot.append(instances, to: .savedInstancesHeader)
|
||||||
instancesSnapshot.append([.addSavedInstance], to: .savedInstancesHeader)
|
instancesSnapshot.append([.addSavedInstance], to: .savedInstancesHeader)
|
||||||
self.dataSource.apply(instancesSnapshot, to: .savedInstances)
|
self.dataSource.apply(instancesSnapshot, to: .savedInstances, animatingDifferences: false) {
|
||||||
|
if let selected = selected {
|
||||||
|
self.collectionView.selectItem(at: selected, animated: false, scrollPosition: .centeredVertically)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func preferencesChanged() {
|
@objc private func preferencesChanged() {
|
||||||
|
@ -297,20 +306,10 @@ class MainSidebarViewController: UIViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func returnToPreviousItem() {
|
|
||||||
let item = previouslySelectedItem ?? .tab(.timelines)
|
|
||||||
previouslySelectedItem = nil
|
|
||||||
select(item: item, animated: true)
|
|
||||||
sidebarDelegate?.sidebar(self, didSelectItem: item)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showAddList() {
|
private func showAddList() {
|
||||||
let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true
|
let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true
|
||||||
) }) { list in
|
) }) { list in
|
||||||
self.select(item: .list(list), animated: false)
|
self.sidebarDelegate?.sidebar(self, didSelectItem: .list(list))
|
||||||
let list = ListTimelineViewController(for: list, mastodonController: self.mastodonController)
|
|
||||||
list.presentEditOnAppear = true
|
|
||||||
self.sidebarDelegate?.sidebar(self, showViewController: list)
|
|
||||||
}
|
}
|
||||||
service.run()
|
service.run()
|
||||||
}
|
}
|
||||||
|
|
|
@ -101,11 +101,7 @@ class MainSplitViewController: UISplitViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func handleSidebarItemCommand(_ sender: AnyObject) {
|
@objc func handleSidebarItemCommand(_ command: UICommand) {
|
||||||
// workaround for crash when sender is not a UICommand, see #253 and FB11804009
|
|
||||||
guard let command = sender as? UICommand else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let item: MainSidebarViewController.Item
|
let item: MainSidebarViewController.Item
|
||||||
if let index = command.propertyList as? Int {
|
if let index = command.propertyList as? Int {
|
||||||
item = .tab(MainTabBarViewController.Tab(rawValue: index)!)
|
item = .tab(MainTabBarViewController.Tab(rawValue: index)!)
|
||||||
|
@ -242,7 +238,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
|
||||||
|
|
||||||
case .discoverHeader, .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance:
|
case .discoverHeader, .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance:
|
||||||
// These items are not selectable in the sidebar collection view, so this code is unreachable.
|
// These items are not selectable in the sidebar collection view, so this code is unreachable.
|
||||||
fatalError("unexpected selected sidebar item: \(sidebar.selectedItem!)")
|
fatalError("unreachable")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -317,7 +313,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
|
||||||
case is ProfileDirectoryViewController:
|
case is ProfileDirectoryViewController:
|
||||||
exploreItem = .profileDirectory
|
exploreItem = .profileDirectory
|
||||||
default:
|
default:
|
||||||
fatalError("unhandled second-level explore screen: \(tabNavigationStack[1])")
|
fatalError("unhandled second-level explore screen")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
transferNavigationStack(from: tabNavController, to: exploreItem!, skipFirst: 1, prepend: toPrepend)
|
transferNavigationStack(from: tabNavController, to: exploreItem!, skipFirst: 1, prepend: toPrepend)
|
||||||
|
@ -358,13 +354,6 @@ extension MainSplitViewController: MainSidebarViewControllerDelegate {
|
||||||
}
|
}
|
||||||
select(item: item)
|
select(item: item)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController) {
|
|
||||||
if let previous = sidebar.previouslySelectedItem {
|
|
||||||
navigationStacks[previous] = secondaryNavController.viewControllers
|
|
||||||
}
|
|
||||||
secondaryNavController.viewControllers = [viewController]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fileprivate extension MainSidebarViewController.Item {
|
fileprivate extension MainSidebarViewController.Item {
|
||||||
|
@ -402,7 +391,8 @@ extension MainSplitViewController: TuskerRootViewController {
|
||||||
return tabBarViewController.stateRestorationActivity()
|
return tabBarViewController.stateRestorationActivity()
|
||||||
} else {
|
} else {
|
||||||
if let timelinePages = navigationStackFor(item: .tab(.timelines))?.first as? TimelinesPageViewController {
|
if let timelinePages = navigationStackFor(item: .tab(.timelines))?.first as? TimelinesPageViewController {
|
||||||
return timelinePages.stateRestorationActivity()
|
let timeline = timelinePages.pageControllers[timelinePages.currentIndex] as! TimelineViewController
|
||||||
|
return timeline.stateRestorationActivity()
|
||||||
} else {
|
} else {
|
||||||
stateRestorationLogger.fault("MainSplitViewController: Unable to create state restoration activity")
|
stateRestorationLogger.fault("MainSplitViewController: Unable to create state restoration activity")
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -13,14 +13,11 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
|
||||||
weak var mastodonController: MastodonController!
|
weak var mastodonController: MastodonController!
|
||||||
|
|
||||||
private var composePlaceholder: UIViewController!
|
private var composePlaceholder: UIViewController!
|
||||||
|
|
||||||
private var fastAccountSwitcher: FastAccountSwitcherViewController!
|
private var fastAccountSwitcher: FastAccountSwitcherViewController!
|
||||||
|
|
||||||
private var fastSwitcherIndicator: FastAccountSwitcherIndicatorView!
|
private var fastSwitcherIndicator: FastAccountSwitcherIndicatorView!
|
||||||
private var fastSwitcherConstraints: [NSLayoutConstraint] = []
|
private var fastSwitcherConstraints: [NSLayoutConstraint] = []
|
||||||
|
|
||||||
@available(iOS, obsoleted: 16.0)
|
|
||||||
private var draftToPresentOnAppear: Draft?
|
|
||||||
|
|
||||||
var selectedTab: Tab {
|
var selectedTab: Tab {
|
||||||
return Tab(rawValue: selectedIndex)!
|
return Tab(rawValue: selectedIndex)!
|
||||||
}
|
}
|
||||||
|
@ -88,11 +85,6 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
stateRestorationLogger.info("MainTabBarViewController: viewDidAppear, selectedIndex=\(self.selectedIndex, privacy: .public)")
|
stateRestorationLogger.info("MainTabBarViewController: viewDidAppear, selectedIndex=\(self.selectedIndex, privacy: .public)")
|
||||||
|
|
||||||
if let draftToPresentOnAppear {
|
|
||||||
self.draftToPresentOnAppear = nil
|
|
||||||
compose(editing: draftToPresentOnAppear, animated: true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidLayoutSubviews() {
|
override func viewDidLayoutSubviews() {
|
||||||
|
@ -243,39 +235,23 @@ extension MainTabBarViewController: TuskerNavigationDelegate {
|
||||||
extension MainTabBarViewController: TuskerRootViewController {
|
extension MainTabBarViewController: TuskerRootViewController {
|
||||||
func stateRestorationActivity() -> NSUserActivity? {
|
func stateRestorationActivity() -> NSUserActivity? {
|
||||||
let nav = viewController(for: .timelines) as! UINavigationController
|
let nav = viewController(for: .timelines) as! UINavigationController
|
||||||
var activity: NSUserActivity?
|
guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController,
|
||||||
if let timelinePages = nav.viewControllers.first as? TimelinesPageViewController {
|
let timelineVC = timelinePages.pageControllers[timelinePages.currentIndex] as? TimelineViewController else {
|
||||||
activity = timelinePages.stateRestorationActivity()
|
stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration actiivty, couldn't find timeline/page VC")
|
||||||
} else {
|
return nil
|
||||||
stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration activity, couldn't find timeline/page VC")
|
|
||||||
}
|
}
|
||||||
if let presentedNav = presentedViewController as? UINavigationController,
|
return timelineVC.stateRestorationActivity()
|
||||||
let compose = presentedNav.viewControllers.first as? ComposeHostingController {
|
|
||||||
activity = UserActivityManager.addEditedDraft(to: activity, draft: compose.draft)
|
|
||||||
}
|
|
||||||
return activity
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreActivity(_ activity: NSUserActivity) {
|
func restoreActivity(_ activity: NSUserActivity) {
|
||||||
func restoreEditedDraft() {
|
|
||||||
// on iOS 16+, this is handled by the duckable container
|
|
||||||
if #unavailable(iOS 16.0),
|
|
||||||
let draft = UserActivityManager.getDraft(from: activity) {
|
|
||||||
draftToPresentOnAppear = draft
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if activity.activityType == UserActivityType.showTimeline.rawValue {
|
if activity.activityType == UserActivityType.showTimeline.rawValue {
|
||||||
let nav = viewController(for: .timelines) as! UINavigationController
|
let nav = viewController(for: .timelines) as! UINavigationController
|
||||||
guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController else {
|
guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController,
|
||||||
|
let timelineVC = timelinePages.pageControllers[timelinePages.currentIndex] as? TimelineViewController else {
|
||||||
stateRestorationLogger.fault("MainTabBarViewController: Unable to restore timeline activity, couldn't find VC")
|
stateRestorationLogger.fault("MainTabBarViewController: Unable to restore timeline activity, couldn't find VC")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
timelinePages.restoreActivity(activity)
|
timelineVC.restoreActivity(activity)
|
||||||
restoreEditedDraft()
|
|
||||||
} else if activity.activityType == UserActivityType.newPost.rawValue {
|
|
||||||
restoreEditedDraft()
|
|
||||||
return
|
|
||||||
} else {
|
} else {
|
||||||
stateRestorationLogger.fault("MainTabBarViewController: Unable to restore activity of unexpected type \(activity.activityType, privacy: .public)")
|
stateRestorationLogger.fault("MainTabBarViewController: Unable to restore activity of unexpected type \(activity.activityType, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 * 24 * 60 * 60,
|
7 * 60 * 60 * 60,
|
||||||
]
|
]
|
||||||
return [
|
return [
|
||||||
.init(value: 0, title: "Forever")
|
.init(value: 0, title: "Forever")
|
||||||
|
@ -40,16 +40,9 @@ struct MuteAccountView: View {
|
||||||
@State private var error: Error?
|
@State private var error: Error?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if #available(iOS 16.0, *) {
|
|
||||||
NavigationStack {
|
|
||||||
navigationViewContent
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
NavigationView {
|
NavigationView {
|
||||||
navigationViewContent
|
navigationViewContent
|
||||||
}
|
}
|
||||||
.navigationViewStyle(.stack)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var navigationViewContent: some View {
|
private var navigationViewContent: some View {
|
||||||
|
@ -114,7 +107,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)
|
||||||
})
|
})
|
||||||
|
|
|
@ -8,7 +8,6 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import Sentry
|
|
||||||
|
|
||||||
class NotificationsTableViewController: DiffableTimelineLikeTableViewController<NotificationsTableViewController.Section, NotificationsTableViewController.Item> {
|
class NotificationsTableViewController: DiffableTimelineLikeTableViewController<NotificationsTableViewController.Section, NotificationsTableViewController.Item> {
|
||||||
|
|
||||||
|
@ -17,7 +16,6 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
|
||||||
private let followGroupCell = "followGroupCell"
|
private let followGroupCell = "followGroupCell"
|
||||||
private let followRequestCell = "followRequestCell"
|
private let followRequestCell = "followRequestCell"
|
||||||
private let pollCell = "pollCell"
|
private let pollCell = "pollCell"
|
||||||
private let updatedCell = "updatedCell"
|
|
||||||
private let unknownCell = "unknownCell"
|
private let unknownCell = "unknownCell"
|
||||||
|
|
||||||
weak var mastodonController: MastodonController!
|
weak var mastodonController: MastodonController!
|
||||||
|
@ -53,7 +51,6 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
|
||||||
tableView.register(UINib(nibName: "FollowNotificationGroupTableViewCell", bundle: .main), forCellReuseIdentifier: followGroupCell)
|
tableView.register(UINib(nibName: "FollowNotificationGroupTableViewCell", bundle: .main), forCellReuseIdentifier: followGroupCell)
|
||||||
tableView.register(UINib(nibName: "FollowRequestNotificationTableViewCell", bundle: .main), forCellReuseIdentifier: followRequestCell)
|
tableView.register(UINib(nibName: "FollowRequestNotificationTableViewCell", bundle: .main), forCellReuseIdentifier: followRequestCell)
|
||||||
tableView.register(UINib(nibName: "PollFinishedTableViewCell", bundle: .main), forCellReuseIdentifier: pollCell)
|
tableView.register(UINib(nibName: "PollFinishedTableViewCell", bundle: .main), forCellReuseIdentifier: pollCell)
|
||||||
tableView.register(UINib(nibName: "StatusUpdatedNotificationTableViewCell", bundle: .main), forCellReuseIdentifier: updatedCell)
|
|
||||||
tableView.register(UINib(nibName: "BasicTableViewCell", bundle: .main), forCellReuseIdentifier: unknownCell)
|
tableView.register(UINib(nibName: "BasicTableViewCell", bundle: .main), forCellReuseIdentifier: unknownCell)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -72,18 +69,7 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
|
||||||
fatalError()
|
fatalError()
|
||||||
}
|
}
|
||||||
cell.delegate = self
|
cell.delegate = self
|
||||||
guard let status = notification.status else {
|
cell.updateUI(statusID: notification.status!.id, state: group.statusState!)
|
||||||
let crumb = Breadcrumb(level: .fatal, category: "notifications")
|
|
||||||
crumb.data = [
|
|
||||||
"id": notification.id,
|
|
||||||
"type": notification.kind.rawValue,
|
|
||||||
"created_at": notification.createdAt.formatted(.iso8601),
|
|
||||||
"account": notification.account.id,
|
|
||||||
]
|
|
||||||
SentrySDK.addBreadcrumb(crumb: crumb)
|
|
||||||
fatalError("missing status for mention notification")
|
|
||||||
}
|
|
||||||
cell.updateUI(statusID: status.id, state: group.statusState!)
|
|
||||||
return cell
|
return cell
|
||||||
|
|
||||||
case .favourite, .reblog:
|
case .favourite, .reblog:
|
||||||
|
@ -112,13 +98,6 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
|
||||||
cell.updateUI(notification: notification)
|
cell.updateUI(notification: notification)
|
||||||
return cell
|
return cell
|
||||||
|
|
||||||
case .update:
|
|
||||||
guard let notification = group.notifications.first,
|
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: updatedCell, for: indexPath) as? StatusUpdatedNotificationTableViewCell else { fatalError() }
|
|
||||||
cell.delegate = self
|
|
||||||
cell.updateUI(notification: notification)
|
|
||||||
return cell
|
|
||||||
|
|
||||||
case .unknown:
|
case .unknown:
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: unknownCell, for: indexPath)
|
let cell = tableView.dequeueReusableCell(withIdentifier: unknownCell, for: indexPath)
|
||||||
cell.textLabel!.text = NSLocalizedString("Unknown Notification", comment: "unknown notification fallback cell text")
|
cell.textLabel!.text = NSLocalizedString("Unknown Notification", comment: "unknown notification fallback cell text")
|
||||||
|
@ -126,19 +105,6 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func validateNotifications(_ notifications: [Pachyderm.Notification]) {
|
|
||||||
for notif in notifications where notif.status == nil && (notif.kind == .mention || notif.kind == .reblog || notif.kind == .favourite) {
|
|
||||||
let crumb = Breadcrumb(level: .fatal, category: "notifications")
|
|
||||||
crumb.data = [
|
|
||||||
"id": notif.id,
|
|
||||||
"type": notif.kind.rawValue,
|
|
||||||
"created_at": notif.createdAt.formatted(.iso8601),
|
|
||||||
"account": notif.account.id,
|
|
||||||
]
|
|
||||||
SentrySDK.addBreadcrumb(crumb: crumb)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
|
override func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
|
||||||
let request = Client.getNotifications(excludeTypes: excludedTypes)
|
let request = Client.getNotifications(excludeTypes: excludedTypes)
|
||||||
mastodonController.run(request) { (response) in
|
mastodonController.run(request) { (response) in
|
||||||
|
@ -147,7 +113,6 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
|
||||||
completion(.failure(.client(error)))
|
completion(.failure(.client(error)))
|
||||||
|
|
||||||
case let .success(notifications, _):
|
case let .success(notifications, _):
|
||||||
self.validateNotifications(notifications)
|
|
||||||
let groups = NotificationGroup.createGroups(notifications: notifications, only: self.groupTypes)
|
let groups = NotificationGroup.createGroups(notifications: notifications, only: self.groupTypes)
|
||||||
|
|
||||||
if !notifications.isEmpty {
|
if !notifications.isEmpty {
|
||||||
|
@ -178,7 +143,6 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
|
||||||
completion(.failure(.client(error)))
|
completion(.failure(.client(error)))
|
||||||
|
|
||||||
case let .success(newNotifications, _):
|
case let .success(newNotifications, _):
|
||||||
self.validateNotifications(newNotifications)
|
|
||||||
if !newNotifications.isEmpty {
|
if !newNotifications.isEmpty {
|
||||||
self.older = .before(id: newNotifications.last!.id, count: nil)
|
self.older = .before(id: newNotifications.last!.id, count: nil)
|
||||||
}
|
}
|
||||||
|
@ -210,7 +174,6 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
|
||||||
completion(.failure(.client(error)))
|
completion(.failure(.client(error)))
|
||||||
|
|
||||||
case let .success(newNotifications, _):
|
case let .success(newNotifications, _):
|
||||||
self.validateNotifications(newNotifications)
|
|
||||||
guard !newNotifications.isEmpty else {
|
guard !newNotifications.isEmpty else {
|
||||||
completion(.failure(.allCaughtUp))
|
completion(.failure(.allCaughtUp))
|
||||||
return
|
return
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,8 +11,6 @@ import CoreData
|
||||||
|
|
||||||
struct AdvancedPrefsView : View {
|
struct AdvancedPrefsView : View {
|
||||||
@ObservedObject var preferences = Preferences.shared
|
@ObservedObject var preferences = Preferences.shared
|
||||||
@State private var imageCacheSize: Int64 = 0
|
|
||||||
@State private var mastodonCacheSize: Int64 = 0
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
|
@ -66,42 +64,13 @@ struct AdvancedPrefsView : View {
|
||||||
}
|
}
|
||||||
|
|
||||||
var cachingSection: some View {
|
var cachingSection: some View {
|
||||||
Section {
|
Section(header: Text("Caching"), footer: Text("Clearing caches will restart the app.")) {
|
||||||
Button(action: clearCache) {
|
Button(action: clearCache) {
|
||||||
Text("Clear Mastodon Cache")
|
Text("Clear Mastodon Cache")
|
||||||
}.foregroundColor(.red)
|
}.foregroundColor(.red)
|
||||||
Button(action: clearImageCaches) {
|
Button(action: clearImageCaches) {
|
||||||
Text("Clear Image Caches")
|
Text("Clear Image Caches")
|
||||||
}.foregroundColor(.red)
|
}.foregroundColor(.red)
|
||||||
} header: {
|
|
||||||
Text("Caching")
|
|
||||||
} footer: {
|
|
||||||
var s: AttributedString = "Clearing caches will restart the app."
|
|
||||||
if imageCacheSize != 0 {
|
|
||||||
s += AttributedString("\nImage cache size: \(ByteCountFormatter().string(fromByteCount: imageCacheSize))")
|
|
||||||
}
|
|
||||||
if mastodonCacheSize != 0 {
|
|
||||||
s += AttributedString("\nMastodon cache size: \(ByteCountFormatter().string(fromByteCount: mastodonCacheSize))")
|
|
||||||
}
|
|
||||||
return Text(s)
|
|
||||||
}.task {
|
|
||||||
imageCacheSize = [
|
|
||||||
ImageCache.avatars,
|
|
||||||
.headers,
|
|
||||||
.attachments,
|
|
||||||
.emojis,
|
|
||||||
].map {
|
|
||||||
$0.getDiskSizeInBytes() ?? 0
|
|
||||||
}.reduce(0, +)
|
|
||||||
mastodonCacheSize = LocalData.shared.accounts.map {
|
|
||||||
let descriptions = MastodonController.getForAccount($0).persistentContainer.persistentStoreDescriptions
|
|
||||||
return descriptions.map {
|
|
||||||
guard let url = $0.url else {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return FileManager.default.recursiveSize(url: url) ?? 0
|
|
||||||
}.reduce(0, +)
|
|
||||||
}.reduce(0, +)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -59,16 +59,6 @@ struct AppearancePrefsView : View {
|
||||||
Toggle(isOn: $preferences.hideActionsInTimeline) {
|
Toggle(isOn: $preferences.hideActionsInTimeline) {
|
||||||
Text("Hide Actions on Timeline")
|
Text("Hide Actions on Timeline")
|
||||||
}
|
}
|
||||||
NavigationLink("Leading Swipe Actions") {
|
|
||||||
SwipeActionsPrefsView(selection: $preferences.leadingStatusSwipeActions)
|
|
||||||
.edgesIgnoringSafeArea(.all)
|
|
||||||
.navigationTitle("Leading Swipe Actions")
|
|
||||||
}
|
|
||||||
NavigationLink("Trailing Swipe Actions") {
|
|
||||||
SwipeActionsPrefsView(selection: $preferences.trailingStatusSwipeActions)
|
|
||||||
.edgesIgnoringSafeArea(.all)
|
|
||||||
.navigationTitle("Trailing Swipe Actions")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,122 +0,0 @@
|
||||||
//
|
|
||||||
// SwipeActionsPrefsView.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 11/26/22.
|
|
||||||
// Copyright © 2022 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct SwipeActionsPrefsView: UIViewControllerRepresentable {
|
|
||||||
@Binding var selection: [StatusSwipeAction]
|
|
||||||
|
|
||||||
typealias UIViewControllerType = SwipeActionsPrefsViewController
|
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> SwipeActionsPrefsViewController {
|
|
||||||
return SwipeActionsPrefsViewController(selection: $selection)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: SwipeActionsPrefsViewController, context: Context) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SwipeActionsPrefsViewController: UIViewController, UICollectionViewDelegate {
|
|
||||||
@Binding var selection: [StatusSwipeAction]
|
|
||||||
|
|
||||||
private var collectionView: UICollectionView {
|
|
||||||
view as! UICollectionView
|
|
||||||
}
|
|
||||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
|
||||||
|
|
||||||
init(selection: Binding<[StatusSwipeAction]>) {
|
|
||||||
self._selection = selection
|
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func loadView() {
|
|
||||||
let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in
|
|
||||||
var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
|
|
||||||
if dataSource.sectionIdentifier(for: sectionIndex) == .selected {
|
|
||||||
config.headerMode = .supplementary
|
|
||||||
}
|
|
||||||
return .list(using: config, layoutEnvironment: environment)
|
|
||||||
}
|
|
||||||
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
|
||||||
collectionView.delegate = self
|
|
||||||
dataSource = createDataSource()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
|
||||||
let listCell = UICollectionView.CellRegistration<UICollectionViewListCell, StatusSwipeAction> { cell, indexPath, item in
|
|
||||||
var config = cell.defaultContentConfiguration()
|
|
||||||
config.text = item.displayName
|
|
||||||
config.image = UIImage(systemName: item.systemImageName)
|
|
||||||
cell.contentConfiguration = config
|
|
||||||
cell.accessories = [.reorder(displayed: .always)]
|
|
||||||
}
|
|
||||||
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
|
|
||||||
return collectionView.dequeueConfiguredReusableCell(using: listCell, for: indexPath, item: itemIdentifier)
|
|
||||||
}
|
|
||||||
let headerCell = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { supplementaryView, elementKind, indexPath in
|
|
||||||
var config = supplementaryView.defaultContentConfiguration()
|
|
||||||
config.text = "Selected"
|
|
||||||
supplementaryView.contentConfiguration = config
|
|
||||||
}
|
|
||||||
dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
|
|
||||||
return collectionView.dequeueConfiguredReusableSupplementary(using: headerCell, for: indexPath)
|
|
||||||
}
|
|
||||||
dataSource.reorderingHandlers.canReorderItem = { _ in
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
dataSource.reorderingHandlers.didReorder = { [unowned self] transaction in
|
|
||||||
guard let selectedSection = transaction.sectionTransactions.first(where: { $0.sectionIdentifier == .selected }) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.selection = self.selection.applying(selectedSection.difference)!
|
|
||||||
}
|
|
||||||
return dataSource
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
setEditing(true, animated: false)
|
|
||||||
applySnapshot(animated: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
|
||||||
collectionView.deselectItem(at: indexPath, animated: true)
|
|
||||||
guard let item = dataSource.itemIdentifier(for: indexPath),
|
|
||||||
let section = dataSource.sectionIdentifier(for: indexPath.section) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch section {
|
|
||||||
case .selected:
|
|
||||||
selection.removeAll(where: { $0 == item })
|
|
||||||
case .remainder:
|
|
||||||
selection.append(item)
|
|
||||||
}
|
|
||||||
applySnapshot(animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applySnapshot(animated: Bool) {
|
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
|
||||||
snapshot.appendSections([.selected, .remainder])
|
|
||||||
snapshot.appendItems(selection, toSection: .selected)
|
|
||||||
snapshot.appendItems(StatusSwipeAction.allCases.filter { !selection.contains($0) }, toSection: .remainder)
|
|
||||||
dataSource.apply(snapshot, animatingDifferences: animated)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Section {
|
|
||||||
case selected
|
|
||||||
case remainder
|
|
||||||
}
|
|
||||||
|
|
||||||
typealias Item = StatusSwipeAction
|
|
||||||
}
|
|
|
@ -14,7 +14,6 @@ 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?
|
||||||
|
@ -39,9 +38,6 @@ 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)
|
|
||||||
self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont
|
|
||||||
self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
|
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
@ -70,11 +66,8 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
if item.hideSeparators {
|
if item.hideSeparators {
|
||||||
config.topSeparatorVisibility = .hidden
|
config.topSeparatorVisibility = .hidden
|
||||||
config.bottomSeparatorVisibility = .hidden
|
config.bottomSeparatorVisibility = .hidden
|
||||||
} else if case .status(id: _, collapseState: _, filterState: let filterState, pinned: _) = item,
|
}
|
||||||
filterer.isKnownHide(state: filterState) {
|
if case .status(_, _, _) = item {
|
||||||
config.topSeparatorVisibility = .hidden
|
|
||||||
config.bottomSeparatorVisibility = .hidden
|
|
||||||
} else if case .status(_, _, _, _) = item {
|
|
||||||
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||||
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||||
}
|
}
|
||||||
|
@ -113,20 +106,14 @@ 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, CollapseState, Filterer.Result, NSAttributedString?, Bool)> { [unowned self] cell, indexPath, item in
|
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState, Bool)> { [unowned self] cell, indexPath, item in
|
||||||
cell.delegate = self
|
cell.delegate = self
|
||||||
cell.showPinned = item.4
|
cell.showPinned = item.2
|
||||||
cell.updateUI(statusID: item.0, state: item.1, filterResult: item.2, precomputedContent: item.3)
|
cell.updateUI(statusID: item.0, state: item.1)
|
||||||
}
|
|
||||||
let zeroHeightCell = UICollectionView.CellRegistration<ZeroHeightCollectionViewCell, Void> { _, _, _ in
|
|
||||||
}
|
}
|
||||||
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
|
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
|
||||||
switch itemIdentifier {
|
switch itemIdentifier {
|
||||||
|
@ -152,14 +139,8 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
self.headerCell = cell
|
self.headerCell = cell
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
case .status(id: let id, collapseState: let collapseState, filterState: let filterState, pinned: let pinned):
|
case .status(id: let id, state: let state, pinned: let pinned):
|
||||||
let (result, precomputedContent) = filterResult(state: filterState, statusID: id)
|
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, pinned))
|
||||||
switch result {
|
|
||||||
case .allow, .warn(_):
|
|
||||||
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, collapseState, result, precomputedContent, pinned))
|
|
||||||
case .hide:
|
|
||||||
return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ())
|
|
||||||
}
|
|
||||||
case .loadingIndicator:
|
case .loadingIndicator:
|
||||||
return loadingIndicatorCell(for: indexPath)
|
return loadingIndicatorCell(for: indexPath)
|
||||||
case .confirmLoadMore:
|
case .confirmLoadMore:
|
||||||
|
@ -244,7 +225,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, collapseState: .unknown, filterState: .unknown, pinned: true)
|
let item = Item.status(id: $0.id, state: .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
|
||||||
|
@ -257,38 +238,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
await apply(snapshot, animatingDifferences: true)
|
await apply(snapshot, animatingDifferences: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func filterResult(state: FilterState, statusID: String) -> (Filterer.Result, NSAttributedString?) {
|
|
||||||
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)
|
||||||
|
@ -339,19 +288,19 @@ extension ProfileStatusesViewController {
|
||||||
typealias TimelineItem = String
|
typealias TimelineItem = String
|
||||||
|
|
||||||
case header(String)
|
case header(String)
|
||||||
case status(id: String, collapseState: CollapseState, filterState: FilterState, pinned: Bool)
|
case status(id: String, state: StatusState, 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, collapseState: .unknown, filterState: .unknown, pinned: false)
|
return .status(id: item, state: .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, _, _, pinned: ap), .status(id: b, _, _, pinned: bp)):
|
case let (.status(id: a, state: _, pinned: ap), .status(id: b, state: _, pinned: bp)):
|
||||||
return a == b && ap == bp
|
return a == b && ap == bp
|
||||||
case (.loadingIndicator, .loadingIndicator):
|
case (.loadingIndicator, .loadingIndicator):
|
||||||
return true
|
return true
|
||||||
|
@ -367,7 +316,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, _, _, pinned: let pinned):
|
case .status(id: let id, state: _, pinned: let pinned):
|
||||||
hasher.combine(1)
|
hasher.combine(1)
|
||||||
hasher.combine(id)
|
hasher.combine(id)
|
||||||
hasher.combine(pinned)
|
hasher.combine(pinned)
|
||||||
|
@ -389,7 +338,7 @@ extension ProfileStatusesViewController {
|
||||||
|
|
||||||
var isSelectable: Bool {
|
var isSelectable: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .status(_, _, _, _):
|
case .status(id: _, state: _, pinned: _):
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
@ -496,20 +445,11 @@ extension ProfileStatusesViewController: UICollectionViewDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
guard let item = dataSource.itemIdentifier(for: indexPath),
|
guard case .status(id: let id, state: let state, pinned: _) = dataSource.itemIdentifier(for: indexPath) else {
|
||||||
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: collapseState.copy())
|
selected(status: status.reblog?.id ?? id, state: state.copy())
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||||
|
@ -542,17 +482,6 @@ 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 {
|
||||||
|
|
|
@ -40,8 +40,6 @@ class SearchResultsViewController: EnhancedTableViewController {
|
||||||
|
|
||||||
/// Types of results to search for. `nil` means all results will be included.
|
/// Types of results to search for. `nil` means all results will be included.
|
||||||
var resultTypes: [SearchResultType]? = nil
|
var resultTypes: [SearchResultType]? = nil
|
||||||
/// Whether to limit results to accounts the users is following.
|
|
||||||
var following: Bool? = nil
|
|
||||||
|
|
||||||
let searchSubject = PassthroughSubject<String?, Never>()
|
let searchSubject = PassthroughSubject<String?, Never>()
|
||||||
var currentQuery: String?
|
var currentQuery: String?
|
||||||
|
@ -79,6 +77,7 @@ class SearchResultsViewController: EnhancedTableViewController {
|
||||||
tableView.trailingAnchor.constraint(equalToSystemSpacingAfter: errorLabel.trailingAnchor, multiplier: 1),
|
tableView.trailingAnchor.constraint(equalToSystemSpacingAfter: errorLabel.trailingAnchor, multiplier: 1),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell)
|
tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell)
|
||||||
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: statusCell)
|
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: statusCell)
|
||||||
tableView.register(UINib(nibName: "HashtagTableViewCell", bundle: .main), forCellReuseIdentifier: hashtagCell)
|
tableView.register(UINib(nibName: "HashtagTableViewCell", bundle: .main), forCellReuseIdentifier: hashtagCell)
|
||||||
|
@ -151,7 +150,7 @@ class SearchResultsViewController: EnhancedTableViewController {
|
||||||
activityIndicator.startAnimating()
|
activityIndicator.startAnimating()
|
||||||
errorLabel.isHidden = true
|
errorLabel.isHidden = true
|
||||||
|
|
||||||
let request = Client.search(query: query, types: resultTypes, resolve: true, limit: 10, following: following)
|
let request = Client.search(query: query, types: resultTypes, resolve: true, limit: 10)
|
||||||
mastodonController.run(request) { (response) in
|
mastodonController.run(request) { (response) in
|
||||||
switch response {
|
switch response {
|
||||||
case let .success(results, _):
|
case let .success(results, _):
|
||||||
|
@ -248,7 +247,7 @@ extension SearchResultsViewController {
|
||||||
enum Item: Hashable {
|
enum Item: Hashable {
|
||||||
case account(String)
|
case account(String)
|
||||||
case hashtag(Hashtag)
|
case hashtag(Hashtag)
|
||||||
case status(String, CollapseState)
|
case status(String, StatusState)
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
func hash(into hasher: inout Hasher) {
|
||||||
switch self {
|
switch self {
|
||||||
|
|
|
@ -270,7 +270,7 @@ extension SearchViewController: UICollectionViewDelegate {
|
||||||
return UIContextMenuConfiguration(identifier: nil) {
|
return UIContextMenuConfiguration(identifier: nil) {
|
||||||
HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController)
|
HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController)
|
||||||
} actionProvider: { (_) in
|
} actionProvider: { (_) in
|
||||||
UIMenu(children: self.actionsForHashtag(hashtag, source: .view(self.collectionView.cellForItem(at: indexPath))))
|
UIMenu(children: self.actionsForHashtag(hashtag, sourceView: self.collectionView.cellForItem(at: indexPath)))
|
||||||
}
|
}
|
||||||
|
|
||||||
case let .link(card):
|
case let .link(card):
|
||||||
|
|
|
@ -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: CollapseState
|
private let statusState: StatusState
|
||||||
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: CollapseState, accountIDs: [String]?, mastodonController: MastodonController) {
|
init(actionType: ActionType, statusID: String, statusState: StatusState, 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, filterResult: .allow, precomputedContent: nil)
|
cell.updateUI(statusID: self.statusID, state: self.statusState)
|
||||||
}
|
}
|
||||||
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
|
||||||
|
@ -120,10 +120,6 @@ class StatusActionAccountListViewController: UIViewController {
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
collectionView.indexPathsForSelectedItems?.forEach {
|
|
||||||
collectionView.deselectItem(at: $0, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if accountIDs == nil {
|
if accountIDs == nil {
|
||||||
Task {
|
Task {
|
||||||
await loadAccounts()
|
await loadAccounts()
|
||||||
|
@ -206,7 +202,7 @@ extension StatusActionAccountListViewController: UICollectionViewDelegate {
|
||||||
return UIContextMenuConfiguration {
|
return UIContextMenuConfiguration {
|
||||||
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
|
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
|
||||||
} actionProvider: { _ in
|
} actionProvider: { _ in
|
||||||
UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell)))
|
UIMenu(children: self.actionsForProfile(accountID: id, sourceView: cell))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -260,10 +256,6 @@ 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 {
|
||||||
|
|
|
@ -14,15 +14,18 @@ class HashtagTimelineViewController: TimelineViewController {
|
||||||
let hashtag: Hashtag
|
let hashtag: Hashtag
|
||||||
|
|
||||||
var toggleSaveButton: UIBarButtonItem!
|
var toggleSaveButton: UIBarButtonItem!
|
||||||
|
var toggleSaveButtonTitle: String {
|
||||||
|
if isHashtagSaved {
|
||||||
|
return NSLocalizedString("Unsave", comment: "unsave hashtag button")
|
||||||
|
} else {
|
||||||
|
return NSLocalizedString("Save", comment: "save hashtag button")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var isHashtagSaved: Bool {
|
private var isHashtagSaved: Bool {
|
||||||
mastodonController.persistentContainer.viewContext.objectExists(for: SavedHashtag.fetchRequest(name: hashtag.name))
|
mastodonController.persistentContainer.viewContext.objectExists(for: SavedHashtag.fetchRequest(name: hashtag.name))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isHashtagFollowed: Bool {
|
|
||||||
mastodonController.followedHashtags.contains(where: { $0.name == hashtag.name })
|
|
||||||
}
|
|
||||||
|
|
||||||
init(for hashtag: Hashtag, mastodonController: MastodonController) {
|
init(for hashtag: Hashtag, mastodonController: MastodonController) {
|
||||||
self.hashtag = hashtag
|
self.hashtag = hashtag
|
||||||
|
|
||||||
|
@ -36,16 +39,19 @@ class HashtagTimelineViewController: TimelineViewController {
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
let menu = UIMenu(children: [
|
toggleSaveButton = UIBarButtonItem(title: toggleSaveButtonTitle, style: .plain, target: self, action: #selector(toggleSaveButtonPressed))
|
||||||
// uncached so that the saved/followed updates every time
|
navigationItem.rightBarButtonItem = toggleSaveButton
|
||||||
UIDeferredMenuElement.uncached({ [unowned self] elementHandler in
|
|
||||||
elementHandler(actionsForHashtag(hashtag, source: .barButtonItem(self.navigationItem.rightBarButtonItem!)))
|
NotificationCenter.default.addObserver(self, selector: #selector(savedHashtagsChanged), name: .savedHashtagsChanged, object: nil)
|
||||||
})
|
|
||||||
])
|
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis"), menu: menu)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleSave() {
|
@objc func savedHashtagsChanged() {
|
||||||
|
toggleSaveButton.title = toggleSaveButtonTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Interaction
|
||||||
|
|
||||||
|
@objc func toggleSaveButtonPressed() {
|
||||||
let context = mastodonController.persistentContainer.viewContext
|
let context = mastodonController.persistentContainer.viewContext
|
||||||
if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first {
|
if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first {
|
||||||
context.delete(existing)
|
context.delete(existing)
|
||||||
|
@ -55,10 +61,4 @@ class HashtagTimelineViewController: TimelineViewController {
|
||||||
mastodonController.persistentContainer.save(context: context)
|
mastodonController.persistentContainer.save(context: context)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleFollow() {
|
|
||||||
Task {
|
|
||||||
await ToggleFollowHashtagService(hashtag: hashtag, presenter: self).toggleFollow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -69,10 +69,10 @@ class InstanceTimelineViewController: TimelineViewController {
|
||||||
toggleSaveButton.title = toggleSaveButtonTitle
|
toggleSaveButton.title = toggleSaveButtonTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
override func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) {
|
override func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: StatusState) {
|
||||||
cell.delegate = browsingEnabled ? self : nil
|
cell.delegate = browsingEnabled ? self : nil
|
||||||
cell.overrideMastodonController = mastodonController
|
cell.overrideMastodonController = mastodonController
|
||||||
cell.updateUI(statusID: id, state: state, filterResult: filterResult, precomputedContent: precomputedContent)
|
cell.updateUI(statusID: id, state: state)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
|
|
@ -10,10 +10,9 @@ import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController, RefreshableViewController {
|
class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, 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>()
|
||||||
|
@ -29,16 +28,6 @@ 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)
|
|
||||||
self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont
|
|
||||||
self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
|
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
@ -70,10 +59,6 @@ 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: _, collapseState: _, filterState: let filterState) = item,
|
|
||||||
filterer.isKnownHide(state: filterState) {
|
|
||||||
config.topSeparatorVisibility = .hidden
|
|
||||||
config.bottomSeparatorVisibility = .hidden
|
|
||||||
} else {
|
} else {
|
||||||
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||||
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||||
|
@ -110,29 +95,18 @@ 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: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) {
|
func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: StatusState) {
|
||||||
cell.delegate = self
|
cell.delegate = self
|
||||||
if case .home = timeline {
|
cell.updateUI(statusID: id, state: state)
|
||||||
cell.showFollowedHashtags = true
|
|
||||||
} else {
|
|
||||||
cell.showFollowedHashtags = false
|
|
||||||
}
|
|
||||||
cell.updateUI(statusID: id, state: state, filterResult: filterResult, precomputedContent: precomputedContent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||||
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, CollapseState, Filterer.Result, NSAttributedString?)> { [unowned self] cell, indexPath, item in
|
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState)> { [unowned self] cell, indexPath, item in
|
||||||
self.configureStatusCell(cell, id: item.0, state: item.1, filterResult: item.2, precomputedContent: item.3)
|
self.configureStatusCell(cell, id: item.0, state: item.1)
|
||||||
}
|
|
||||||
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
|
||||||
|
@ -149,14 +123,8 @@ 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, collapseState: let state, filterState: let filterState):
|
case .status(id: let id, state: let state):
|
||||||
let (result, attributedString) = filterResult(state: filterState, statusID: id)
|
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state))
|
||||||
switch result {
|
|
||||||
case .allow, .warn(_):
|
|
||||||
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, result, nil))
|
|
||||||
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:
|
||||||
|
@ -256,14 +224,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, _, _) = $0 {
|
if case .status(id: let id, state: _) = $0 {
|
||||||
return id
|
return id
|
||||||
} else {
|
} else {
|
||||||
fatalError()
|
fatalError()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let centerVisibleID: String
|
let centerVisibleID: String
|
||||||
if case .status(id: let id, _, _) = centerVisibleItem {
|
if case .status(id: let id, state: _) = centerVisibleItem {
|
||||||
centerVisibleID = id
|
centerVisibleID = id
|
||||||
} else {
|
} else {
|
||||||
fatalError()
|
fatalError()
|
||||||
|
@ -284,10 +252,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
}
|
}
|
||||||
|
|
||||||
private func doRestore() -> Bool {
|
private func doRestore() -> Bool {
|
||||||
guard let activity = activityToRestore else {
|
guard let activity = activityToRestore,
|
||||||
return false
|
let statusIDs = activity.userInfo?["statusIDs"] as? [String] else {
|
||||||
}
|
|
||||||
guard let statusIDs = activity.userInfo?["statusIDs"] as? [String] else {
|
|
||||||
stateRestorationLogger.fault("TimelineViewController: activity missing statusIDs")
|
stateRestorationLogger.fault("TimelineViewController: activity missing statusIDs")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -296,7 +262,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, collapseState: .unknown, filterState: .unknown) }
|
let items = statusIDs.map { Item.status(id: $0, state: .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,
|
||||||
|
@ -331,40 +297,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
isShowingTimelineDescription = false
|
isShowingTimelineDescription = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func filterResult(state: FilterState, statusID: String) -> (Filterer.Result, NSAttributedString?) {
|
|
||||||
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
|
||||||
|
@ -380,24 +312,16 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
Task {
|
Task {
|
||||||
if case .notLoadedInitial = controller.state {
|
if case .notLoadedInitial = controller.state {
|
||||||
await controller.loadInitial()
|
await controller.loadInitial()
|
||||||
#if !targetEnvironment(macCatalyst)
|
|
||||||
collectionView.refreshControl?.endRefreshing()
|
|
||||||
#endif
|
|
||||||
} else {
|
} else {
|
||||||
@MainActor
|
|
||||||
func loadNewerAndEndRefreshing() async {
|
|
||||||
await controller.loadNewer()
|
|
||||||
#if !targetEnvironment(macCatalyst)
|
|
||||||
collectionView.refreshControl?.endRefreshing()
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// I'm not sure whether this should move into TimelineLikeController/TimelineLikeCollectionViewController
|
// I'm not sure whether this should move into TimelineLikeController/TimelineLikeCollectionViewController
|
||||||
let (_, presentItems) = await (loadNewerAndEndRefreshing(), try? loadInitial())
|
let (_, presentItems) = await (controller.loadNewer(), try? loadInitial())
|
||||||
if let presentItems, !presentItems.isEmpty {
|
if let presentItems {
|
||||||
insertPresentItemsIfNecessary(presentItems)
|
insertPresentItemsIfNecessary(presentItems)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if !targetEnvironment(macCatalyst)
|
||||||
|
collectionView.refreshControl?.endRefreshing()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -409,19 +333,18 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
}
|
}
|
||||||
|
|
||||||
private func insertPresentItemsIfNecessary(_ presentItems: [String]) {
|
private func insertPresentItemsIfNecessary(_ presentItems: [String]) {
|
||||||
let snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
guard snapshot.indexOfSection(.statuses) != nil else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let currentItems = snapshot.itemIdentifiers(inSection: .statuses)
|
let currentItems = snapshot.itemIdentifiers(inSection: .statuses)
|
||||||
if case .status(id: let firstID, _, _) = currentItems.first,
|
if case .status(id: let firstID, state: _) = 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, insert the present items and prompt the user
|
||||||
!presentItems.contains(firstID) {
|
!presentItems.contains(firstID) {
|
||||||
|
|
||||||
// create a new snapshot to reset the timeline to the "present" state
|
// remove any existing gap, if there is one
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
if let index = currentItems.lastIndex(of: .gap) {
|
||||||
snapshot.appendSections([.statuses])
|
snapshot.deleteItems(Array(currentItems[index...]))
|
||||||
snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses)
|
}
|
||||||
|
snapshot.insertItems([.gap], beforeItem: currentItems.first!)
|
||||||
|
snapshot.insertItems(presentItems.map { .status(id: $0, state: .unknown) }, beforeItem: .gap)
|
||||||
|
|
||||||
var config = ToastConfiguration(title: "Jump to present")
|
var config = ToastConfiguration(title: "Jump to present")
|
||||||
config.edge = .top
|
config.edge = .top
|
||||||
|
@ -430,34 +353,12 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
config.action = { [unowned self] toast in
|
config.action = { [unowned self] toast in
|
||||||
toast.dismissToast(animated: true)
|
toast.dismissToast(animated: true)
|
||||||
|
|
||||||
let origSnapshot = self.dataSource.snapshot()
|
|
||||||
let origItemAtTop: (Item, CGFloat)?
|
|
||||||
if let statusesSection = origSnapshot.indexOfSection(.statuses),
|
|
||||||
let indexPath = self.collectionView.indexPathsForVisibleItems.sorted().first(where: { $0.section == statusesSection }),
|
|
||||||
let cell = self.collectionView.cellForItem(at: indexPath),
|
|
||||||
let item = self.dataSource.itemIdentifier(for: indexPath) {
|
|
||||||
origItemAtTop = (item, cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top)
|
|
||||||
} else {
|
|
||||||
origItemAtTop = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
self.dataSource.apply(snapshot, animatingDifferences: true) {
|
self.dataSource.apply(snapshot, animatingDifferences: true) {
|
||||||
|
// TODO: we can't set prevScrollOffsetBeforeScrollToTop here to allow undoing the scroll-to-top
|
||||||
|
// because that would involve scrolling through unmeasured-cell which fucks up the content offset values.
|
||||||
|
// we probably need a data-source aware implementation of scrollToTop which uses item & offset w/in item
|
||||||
|
// to track the restore position
|
||||||
self.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: true)
|
self.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: true)
|
||||||
|
|
||||||
var config = ToastConfiguration(title: "Go back")
|
|
||||||
config.edge = .top
|
|
||||||
config.systemImageName = "arrow.down"
|
|
||||||
config.dismissAutomaticallyAfter = 4
|
|
||||||
config.action = { [unowned self] toast in
|
|
||||||
toast.dismissToast(animated: true)
|
|
||||||
// todo: it would be nice if we could animate this, but that doesn't work with the screen-position-maintaining stuff
|
|
||||||
if let (item, offset) = origItemAtTop {
|
|
||||||
self.applySnapshot(snapshot, maintainingScreenPosition: offset, ofItem: item)
|
|
||||||
} else {
|
|
||||||
self.dataSource.apply(origSnapshot, animatingDifferences: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.showToast(configuration: config, animated: true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.showToast(configuration: config, animated: true)
|
self.showToast(configuration: config, animated: true)
|
||||||
|
@ -466,35 +367,32 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
|
|
||||||
// NOTE: this only works when items are being inserted ABOVE the item to maintain
|
// NOTE: this only works when items are being inserted ABOVE the item to maintain
|
||||||
private func applySnapshot(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, maintainingBottomRelativeScrollPositionOf itemToMaintain: Item) {
|
private func applySnapshot(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, maintainingBottomRelativeScrollPositionOf itemToMaintain: Item) {
|
||||||
var firstItemAfterOriginalGapOffsetFromTop: CGFloat = 0
|
|
||||||
if let indexPath = dataSource.indexPath(for: itemToMaintain),
|
|
||||||
let cell = collectionView.cellForItem(at: indexPath) {
|
|
||||||
// subtract top safe area inset b/c scrollToItem at .top aligns the top of the cell to the top of the safe area
|
|
||||||
firstItemAfterOriginalGapOffsetFromTop = cell.convert(.zero, to: view).y - view.safeAreaInsets.top
|
|
||||||
}
|
|
||||||
applySnapshot(snapshot, maintainingScreenPosition: firstItemAfterOriginalGapOffsetFromTop, ofItem: itemToMaintain)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applySnapshot(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, maintainingScreenPosition offsetFromTop: CGFloat, ofItem itemToMaintain: Item) {
|
|
||||||
// use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back
|
// use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back
|
||||||
let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)!
|
let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)!
|
||||||
snapshotView.layer.zPosition = 1000
|
snapshotView.layer.zPosition = 1000
|
||||||
snapshotView.frame = view.bounds
|
snapshotView.frame = view.bounds
|
||||||
view.addSubview(snapshotView)
|
view.addSubview(snapshotView)
|
||||||
|
|
||||||
|
var firstItemAfterOriginalGapOffsetFromTop: CGFloat = 0
|
||||||
|
if let indexPath = dataSource.indexPath(for: itemToMaintain),
|
||||||
|
let cell = collectionView.cellForItem(at: indexPath) {
|
||||||
|
// subtract top safe area inset b/c scrollToItem at .top aligns the top of the cell to the top of the safe area
|
||||||
|
firstItemAfterOriginalGapOffsetFromTop = cell.convert(.zero, to: view).y - view.safeAreaInsets.top
|
||||||
|
}
|
||||||
|
|
||||||
dataSource.apply(snapshot, animatingDifferences: false) {
|
dataSource.apply(snapshot, animatingDifferences: false) {
|
||||||
if let indexPathOfItemToMaintain = self.dataSource.indexPath(for: itemToMaintain) {
|
if let indexPathOfItemAfterOriginalGap = self.dataSource.indexPath(for: itemToMaintain) {
|
||||||
// scroll up until we've accumulated enough MEASURED height that we can put the
|
// scroll up until we've accumulated enough MEASURED height that we can put the
|
||||||
// firstItemAfterOriginalGapCell at the top of the screen and then scroll down by
|
// firstItemAfterOriginalGapCell at the top of the screen and then scroll down by
|
||||||
// firstItemAfterOriginalGapOffsetFromTop without intruding into unmeasured area
|
// firstItemAfterOriginalGapOffsetFromTop without intruding into unmeasured area
|
||||||
var cur = indexPathOfItemToMaintain
|
var cur = indexPathOfItemAfterOriginalGap
|
||||||
var amountScrolledUp: CGFloat = 0
|
var amountScrolledUp: CGFloat = 0
|
||||||
while true {
|
while true {
|
||||||
if cur.row <= 0 {
|
if cur.row <= 0 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if let cell = self.collectionView.cellForItem(at: indexPathOfItemToMaintain),
|
if let cell = self.collectionView.cellForItem(at: indexPathOfItemAfterOriginalGap),
|
||||||
cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top > offsetFromTop {
|
cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top > firstItemAfterOriginalGapOffsetFromTop {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
cur = IndexPath(row: cur.row - 1, section: cur.section)
|
cur = IndexPath(row: cur.row - 1, section: cur.section)
|
||||||
|
@ -504,7 +402,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
amountScrolledUp += attrs.size.height
|
amountScrolledUp += attrs.size.height
|
||||||
}
|
}
|
||||||
self.collectionView.contentOffset.y += amountScrolledUp
|
self.collectionView.contentOffset.y += amountScrolledUp
|
||||||
self.collectionView.contentOffset.y -= offsetFromTop
|
self.collectionView.contentOffset.y -= firstItemAfterOriginalGapOffsetFromTop
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshotView.removeFromSuperview()
|
snapshotView.removeFromSuperview()
|
||||||
|
@ -524,19 +422,19 @@ extension TimelineViewController {
|
||||||
enum Item: TimelineLikeCollectionViewItem {
|
enum Item: TimelineLikeCollectionViewItem {
|
||||||
typealias TimelineItem = String // status ID
|
typealias TimelineItem = String // status ID
|
||||||
|
|
||||||
case status(id: String, collapseState: CollapseState, filterState: FilterState)
|
case status(id: String, state: StatusState)
|
||||||
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, collapseState: .unknown, filterState: .unknown)
|
return .status(id: id, state: .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, _, _), .status(id: b, _, _)):
|
case let (.status(id: a, state: _), .status(id: b, state: _)):
|
||||||
return a == b
|
return a == b
|
||||||
case (.gap, .gap):
|
case (.gap, .gap):
|
||||||
return true
|
return true
|
||||||
|
@ -553,7 +451,7 @@ extension TimelineViewController {
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
func hash(into hasher: inout Hasher) {
|
||||||
switch self {
|
switch self {
|
||||||
case .status(id: let id, _, _):
|
case .status(id: let id, state: _):
|
||||||
hasher.combine(0)
|
hasher.combine(0)
|
||||||
hasher.combine(id)
|
hasher.combine(id)
|
||||||
case .gap:
|
case .gap:
|
||||||
|
@ -578,7 +476,7 @@ extension TimelineViewController {
|
||||||
|
|
||||||
var isSelectable: Bool {
|
var isSelectable: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .publicTimelineDescription, .gap, .status(_, _, _):
|
case .publicTimelineDescription, .gap, .status(id: _, state: _):
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
@ -608,7 +506,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, _, _) = dataSource.itemIdentifier(for: IndexPath(row: 0, section: statusesSection)) else {
|
guard case .status(id: let id, state: _) = 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)
|
||||||
|
@ -632,7 +530,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, _, _) = dataSource.itemIdentifier(for: IndexPath(row: snapshot.numberOfItems(inSection: .statuses) - 1, section: statusesSection)) else {
|
guard case .status(id: let id, state: _) = 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)
|
||||||
|
@ -662,14 +560,14 @@ extension TimelineViewController {
|
||||||
switch direction {
|
switch direction {
|
||||||
case .above:
|
case .above:
|
||||||
guard gapIndexPath.row > 0,
|
guard gapIndexPath.row > 0,
|
||||||
case .status(id: let id, _, _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row - 1, section: gapIndexPath.section)) else {
|
case .status(id: let id, state: _) = 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, _, _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row + 1, section: gapIndexPath.section)) else {
|
case .status(id: let id, state: _) = 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)
|
||||||
|
@ -719,13 +617,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, _, _) = afterGap.first {
|
if case .status(id: let id, state: _) = 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, collapseState: .unknown, filterState: .unknown) }
|
let toInsert = timelineItems[..<endIndex].map { Item.status(id: $0, state: .unknown) }
|
||||||
if toInsert.isEmpty {
|
if toInsert.isEmpty {
|
||||||
addedItems = false
|
addedItems = false
|
||||||
} else {
|
} else {
|
||||||
|
@ -747,7 +645,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, _, _) = beforeGap.last {
|
if case .status(id: let id, state: _) = beforeGap.last {
|
||||||
indexOfLastTimelineItemExistingAboveGap = timelineItems.lastIndex(of: id)
|
indexOfLastTimelineItemExistingAboveGap = timelineItems.lastIndex(of: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -759,7 +657,7 @@ extension TimelineViewController {
|
||||||
} else {
|
} else {
|
||||||
startIndex = timelineItems.startIndex
|
startIndex = timelineItems.startIndex
|
||||||
}
|
}
|
||||||
let toInsert = timelineItems[startIndex...].map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
|
let toInsert = timelineItems[startIndex...].map { Item.status(id: $0, state: .unknown) }
|
||||||
if toInsert.isEmpty {
|
if toInsert.isEmpty {
|
||||||
addedItems = false
|
addedItems = false
|
||||||
} else {
|
} else {
|
||||||
|
@ -823,18 +721,10 @@ extension TimelineViewController: UICollectionViewDelegate {
|
||||||
switch item {
|
switch item {
|
||||||
case .publicTimelineDescription:
|
case .publicTimelineDescription:
|
||||||
removeTimelineDescriptionCell()
|
removeTimelineDescriptionCell()
|
||||||
case .status(id: let id, collapseState: let collapseState, filterState: let filterState):
|
case .status(id: let id, state: let state):
|
||||||
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: collapseState.copy())
|
selected(status: status.reblog?.id ?? id, state: state.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
|
||||||
|
@ -883,17 +773,6 @@ 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,7 +7,6 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
class TimelinesPageViewController: SegmentedPageViewController {
|
class TimelinesPageViewController: SegmentedPageViewController {
|
||||||
|
|
||||||
|
@ -41,42 +40,28 @@ 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) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
func stateRestorationActivity() -> NSUserActivity? {
|
|
||||||
return (pageControllers[currentIndex] as! TimelineViewController).stateRestorationActivity()
|
|
||||||
}
|
|
||||||
|
|
||||||
func restoreActivity(_ activity: NSUserActivity) {
|
func restoreActivity(_ activity: NSUserActivity) {
|
||||||
guard let timeline = UserActivityManager.getTimeline(from: activity) else {
|
guard let timeline = UserActivityManager.getTimeline(from: activity) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let index: Int
|
|
||||||
switch timeline {
|
switch timeline {
|
||||||
case .home:
|
case .home:
|
||||||
index = 0
|
selectPage(at: 0, animated: false)
|
||||||
case .public(local: false):
|
case .public(local: false):
|
||||||
index = 1
|
selectPage(at: 1, animated: false)
|
||||||
case .public(local: true):
|
case .public(local: true):
|
||||||
index = 2
|
selectPage(at: 2, animated: false)
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
selectPage(at: index, animated: false)
|
let timelineVC = pageControllers[currentIndex] as! TimelineViewController
|
||||||
let timelineVC = pageControllers[index] as! TimelineViewController
|
|
||||||
timelineVC.restoreActivity(activity)
|
timelineVC.restoreActivity(activity)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func filtersPressed() {
|
|
||||||
present(UIHostingController(rootView: FiltersView(mastodonController: mastodonController)), animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
//
|
|
||||||
// CollectionViewController.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 11/29/22.
|
|
||||||
// Copyright © 2022 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
protocol CollectionViewController: UIViewController {
|
|
||||||
var collectionView: UICollectionView! { get }
|
|
||||||
}
|
|
|
@ -39,15 +39,15 @@ extension MenuActionProvider {
|
||||||
|
|
||||||
private var mastodonController: MastodonController? { navigationDelegate?.apiController }
|
private var mastodonController: MastodonController? { navigationDelegate?.apiController }
|
||||||
|
|
||||||
func actionsForProfile(accountID: String, source: PopoverSource) -> [UIMenuElement] {
|
func actionsForProfile(accountID: String, sourceView: UIView?) -> [UIMenuElement] {
|
||||||
guard let mastodonController = mastodonController,
|
guard let mastodonController = mastodonController,
|
||||||
let account = mastodonController.persistentContainer.account(for: accountID) else { return [] }
|
let account = mastodonController.persistentContainer.account(for: accountID) else { return [] }
|
||||||
|
|
||||||
var shareSection = [
|
var shareSection = [
|
||||||
openInSafariAction(url: account.url),
|
openInSafariAction(url: account.url),
|
||||||
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
|
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.navigationDelegate?.showMoreOptions(forAccount: accountID, source: source)
|
self.navigationDelegate?.showMoreOptions(forAccount: accountID, sourceView: sourceView)
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -95,27 +95,24 @@ extension MenuActionProvider {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
func actionsForURL(_ url: URL, source: PopoverSource) -> [UIAction] {
|
func actionsForURL(_ url: URL, sourceView: UIView?) -> [UIAction] {
|
||||||
return [
|
return [
|
||||||
openInSafariAction(url: url),
|
openInSafariAction(url: url),
|
||||||
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
|
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.navigationDelegate?.showMoreOptions(forURL: url, source: source)
|
self.navigationDelegate?.showMoreOptions(forURL: url, sourceView: sourceView)
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
func actionsForHashtag(_ hashtag: Hashtag, source: PopoverSource) -> [UIMenuElement] {
|
func actionsForHashtag(_ hashtag: Hashtag, sourceView: UIView?) -> [UIMenuElement] {
|
||||||
var actionsSection: [UIMenuElement] = []
|
let actionsSection: [UIMenuElement]
|
||||||
if let mastodonController = mastodonController,
|
if let mastodonController = mastodonController,
|
||||||
mastodonController.loggedIn {
|
mastodonController.loggedIn {
|
||||||
let name = hashtag.name.lowercased()
|
|
||||||
let context = mastodonController.persistentContainer.viewContext
|
let context = mastodonController.persistentContainer.viewContext
|
||||||
let existing = try? context.fetch(SavedHashtag.fetchRequest(name: name)).first
|
let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first
|
||||||
let saveSubtitle = "Saved hashtags appear in the Explore section of Tusker"
|
|
||||||
let saveImage = UIImage(systemName: existing != nil ? "minus" : "plus")
|
|
||||||
actionsSection = [
|
actionsSection = [
|
||||||
UIAction(title: existing != nil ? "Unsave Hashtag" : "Save Hashtag", subtitle: saveSubtitle, image: saveImage, handler: { (_) in
|
createAction(identifier: "save", title: existing != nil ? "Unsave Hashtag" : "Save Hashtag", systemImageName: "number", handler: { (_) in
|
||||||
if let existing = existing {
|
if let existing = existing {
|
||||||
context.delete(existing)
|
context.delete(existing)
|
||||||
} else {
|
} else {
|
||||||
|
@ -124,21 +121,13 @@ extension MenuActionProvider {
|
||||||
mastodonController.persistentContainer.save(context: context)
|
mastodonController.persistentContainer.save(context: context)
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
if mastodonController.instanceFeatures.canFollowHashtags {
|
} else {
|
||||||
let existing = mastodonController.followedHashtags.first(where: { $0.name.lowercased() == name })
|
actionsSection = []
|
||||||
let subtitle = "Posts tagged with followed hashtags appear in your Home timeline"
|
|
||||||
let image = UIImage(systemName: existing != nil ? "person.badge.minus" : "person.badge.plus")
|
|
||||||
actionsSection.append(UIAction(title: existing != nil ? "Unfollow" : "Follow", subtitle: subtitle, image: image) { [unowned self] _ in
|
|
||||||
Task {
|
|
||||||
await ToggleFollowHashtagService(hashtag: hashtag, presenter: navigationDelegate!).toggleFollow()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let shareSection: [UIMenuElement]
|
let shareSection: [UIMenuElement]
|
||||||
if let url = URL(hashtag.url) {
|
if let url = URL(hashtag.url) {
|
||||||
shareSection = actionsForURL(url, source: source)
|
shareSection = actionsForURL(url, sourceView: sourceView)
|
||||||
} else {
|
} else {
|
||||||
shareSection = []
|
shareSection = []
|
||||||
}
|
}
|
||||||
|
@ -149,16 +138,16 @@ extension MenuActionProvider {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
func actionsForStatus(_ status: StatusMO, source: PopoverSource, includeStatusButtonActions: Bool = true) -> [UIMenuElement] {
|
func actionsForStatus(_ status: StatusMO, sourceView: UIView?, includeStatusButtonActions: Bool = true) -> [UIMenuElement] {
|
||||||
guard let mastodonController = mastodonController else { return [] }
|
guard let mastodonController = mastodonController else { return [] }
|
||||||
|
|
||||||
guard let accountID = mastodonController.accountInfo?.id else {
|
guard let accountID = mastodonController.accountInfo?.id else {
|
||||||
// user is logged out
|
// user is logged out
|
||||||
return [
|
return [
|
||||||
openInSafariAction(url: status.url!),
|
openInSafariAction(url: status.url!),
|
||||||
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
|
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.navigationDelegate?.showMoreOptions(forStatus: status.id, source: source)
|
self.navigationDelegate?.showMoreOptions(forStatus: status.id, sourceView: sourceView)
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -282,9 +271,9 @@ extension MenuActionProvider {
|
||||||
} else {
|
} else {
|
||||||
Logging.general.fault("Status missing URL: id=\(status.id, privacy: .public), reblog=\((status.reblog?.id).debugDescription, privacy: .public)")
|
Logging.general.fault("Status missing URL: id=\(status.id, privacy: .public), reblog=\((status.reblog?.id).debugDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
shareSection.append(createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
|
shareSection.append(createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.navigationDelegate?.showMoreOptions(forStatus: status.id, source: source)
|
self.navigationDelegate?.showMoreOptions(forStatus: status.id, sourceView: sourceView)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID))
|
addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID))
|
||||||
|
@ -366,17 +355,6 @@ extension MenuActionProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleSuccess(title: String) {
|
|
||||||
if let toastable = self.toastableViewController {
|
|
||||||
var config = ToastConfiguration(title: title)
|
|
||||||
config.systemImageName = "checkmark"
|
|
||||||
config.dismissAutomaticallyAfter = 2
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
toastable.showToast(configuration: config, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func relationshipAction(accountID: String, mastodonController: MastodonController, builder: @escaping @MainActor (RelationshipMO, MastodonController) -> UIMenuElement) -> UIDeferredMenuElement {
|
private func relationshipAction(accountID: String, mastodonController: MastodonController, builder: @escaping @MainActor (RelationshipMO, MastodonController) -> UIMenuElement) -> UIDeferredMenuElement {
|
||||||
return UIDeferredMenuElement.uncached({ @MainActor elementHandler in
|
return UIDeferredMenuElement.uncached({ @MainActor elementHandler in
|
||||||
let relationship = Task {
|
let relationship = Task {
|
||||||
|
@ -402,26 +380,14 @@ extension MenuActionProvider {
|
||||||
private func followAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement {
|
private func followAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement {
|
||||||
let accountID = relationship.accountID
|
let accountID = relationship.accountID
|
||||||
let following = relationship.following
|
let following = relationship.following
|
||||||
let requested = relationship.requested
|
return createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.plus") { [weak self] _ in
|
||||||
let title = following ? "Unfollow" : requested ? "Cancel Request" : "Follow"
|
let request = (following ? Account.unfollow : Account.follow)(accountID)
|
||||||
let imageName = following || requested ? "person.badge.minus" : "person.badge.plus"
|
|
||||||
return createAction(identifier: "follow", title: title, systemImageName: imageName) { [weak self] _ in
|
|
||||||
let request = (following || requested ? Account.unfollow : Account.follow)(accountID)
|
|
||||||
mastodonController.run(request) { response in
|
mastodonController.run(request) { response in
|
||||||
switch response {
|
switch response {
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
self?.handleError(error, title: following ? "Error Unfollowing" : requested ? "Error Cancelinng Request" : "Error Following")
|
self?.handleError(error, title: "Error \(following ? "Unf" : "F")ollowing")
|
||||||
case .success(let relationship, _):
|
case .success(let relationship, _):
|
||||||
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
|
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
|
||||||
if requested { // was requested, now cancelled
|
|
||||||
self?.handleSuccess(title: "Follow Request Cancelled")
|
|
||||||
} else if following { // was following, now unfollowed
|
|
||||||
self?.handleSuccess(title: "Unfollowed")
|
|
||||||
} else if relationship.followRequested { // was not following, now requested
|
|
||||||
self?.handleSuccess(title: "Request Sent")
|
|
||||||
} else { // was not following, not now requested, assume success
|
|
||||||
self?.handleSuccess(title: "Followed")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -441,7 +407,6 @@ extension MenuActionProvider {
|
||||||
self?.handleError(error, title: "Error \(block ? "B" : "Unb")locking")
|
self?.handleError(error, title: "Error \(block ? "B" : "Unb")locking")
|
||||||
case .success(let relationship, _):
|
case .success(let relationship, _):
|
||||||
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
|
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
|
||||||
self?.handleSuccess(title: "\(block ? "B" : "Unb")locked")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -450,11 +415,8 @@ extension MenuActionProvider {
|
||||||
return { [weak self] (_: UIAction) in
|
return { [weak self] (_: UIAction) in
|
||||||
let req = block ? Client.block(domain: host) : Client.unblock(domain: host)
|
let req = block ? Client.block(domain: host) : Client.unblock(domain: host)
|
||||||
mastodonController.run(req) { response in
|
mastodonController.run(req) { response in
|
||||||
switch response {
|
if case .failure(let error) = response {
|
||||||
case .failure(let error):
|
|
||||||
self?.handleError(error, title: "Error \(block ? "B" : "Unb")locking")
|
self?.handleError(error, title: "Error \(block ? "B" : "Unb")locking")
|
||||||
case .success(_, _):
|
|
||||||
self?.handleSuccess(title: "Domain \(block ? "B" : "Unb")locked")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -484,7 +446,6 @@ extension MenuActionProvider {
|
||||||
self?.handleError(error, title: "Error Unmuting")
|
self?.handleError(error, title: "Error Unmuting")
|
||||||
case .success(let relationship, _):
|
case .success(let relationship, _):
|
||||||
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
|
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
|
||||||
self?.handleSuccess(title: "Unmuted")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,6 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
|
||||||
let titles: [String]
|
let titles: [String]
|
||||||
let pageControllers: [UIViewController]
|
let pageControllers: [UIViewController]
|
||||||
|
|
||||||
private var initialIndex = 0
|
|
||||||
private(set) var currentIndex = 0
|
private(set) var currentIndex = 0
|
||||||
|
|
||||||
var segmentedControl: UISegmentedControl!
|
var segmentedControl: UISegmentedControl!
|
||||||
|
@ -44,7 +43,7 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
|
||||||
|
|
||||||
view.backgroundColor = .systemBackground
|
view.backgroundColor = .systemBackground
|
||||||
|
|
||||||
selectPage(at: initialIndex, animated: false)
|
selectPage(at: 0, animated: false)
|
||||||
|
|
||||||
addKeyCommand(MenuController.prevSubTabCommand)
|
addKeyCommand(MenuController.prevSubTabCommand)
|
||||||
addKeyCommand(MenuController.nextSubTabCommand)
|
addKeyCommand(MenuController.nextSubTabCommand)
|
||||||
|
@ -58,10 +57,6 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectPage(at index: Int, animated: Bool) {
|
func selectPage(at index: Int, animated: Bool) {
|
||||||
guard isViewLoaded else {
|
|
||||||
initialIndex = index
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let direction: UIPageViewController.NavigationDirection = index - currentIndex > 0 ? .forward : .reverse
|
let direction: UIPageViewController.NavigationDirection = index - currentIndex > 0 ? .forward : .reverse
|
||||||
setViewControllers([pageControllers[index]], direction: direction, animated: animated)
|
setViewControllers([pageControllers[index]], direction: direction, animated: animated)
|
||||||
navigationItem.title = pageControllers[index].title
|
navigationItem.title = pageControllers[index].title
|
||||||
|
|
|
@ -56,7 +56,7 @@ class SplitNavigationController: UIViewController {
|
||||||
let selectedIndexPath = tableVC.tableView.indexPathForSelectedRow {
|
let selectedIndexPath = tableVC.tableView.indexPathForSelectedRow {
|
||||||
tableVC.tableView.deselectRow(at: selectedIndexPath, animated: true)
|
tableVC.tableView.deselectRow(at: selectedIndexPath, animated: true)
|
||||||
} else if let sender = sender as? UIViewController,
|
} else if let sender = sender as? UIViewController,
|
||||||
let collectionView = (sender as? CollectionViewController)?.collectionView ?? sender.view as? UICollectionView {
|
let collectionView = sender.view as? UICollectionView {
|
||||||
// the collection view's animation speed is weirdly fast, so we do it slower
|
// the collection view's animation speed is weirdly fast, so we do it slower
|
||||||
UIView.animate(withDuration: 0.5, delay: 0) {
|
UIView.animate(withDuration: 0.5, delay: 0) {
|
||||||
collectionView.indexPathsForSelectedItems?.forEach { collectionView.deselectItem(at: $0, animated: false) }
|
collectionView.indexPathsForSelectedItems?.forEach { collectionView.deselectItem(at: $0, animated: false) }
|
||||||
|
|
|
@ -1,61 +0,0 @@
|
||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -91,37 +91,10 @@ class UserActivityManager {
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
|
|
||||||
static func addDuckedDraft(to activity: NSUserActivity?, draft: Draft) -> NSUserActivity {
|
|
||||||
if let activity {
|
|
||||||
activity.addUserInfoEntries(from: [
|
|
||||||
"duckedDraftID": draft.id.uuidString
|
|
||||||
])
|
|
||||||
return activity
|
|
||||||
} else {
|
|
||||||
return editDraftActivity(id: draft.id, accountID: draft.accountID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func addEditedDraft(to activity: NSUserActivity?, draft: Draft) -> NSUserActivity {
|
|
||||||
if let activity {
|
|
||||||
activity.addUserInfoEntries(from: [
|
|
||||||
"editedDraftID": draft.id.uuidString
|
|
||||||
])
|
|
||||||
return activity
|
|
||||||
} else {
|
|
||||||
return editDraftActivity(id: draft.id, accountID: draft.accountID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func getDraft(from activity: NSUserActivity) -> Draft? {
|
static func getDraft(from activity: NSUserActivity) -> Draft? {
|
||||||
let idStr: String?
|
guard activity.activityType == UserActivityType.newPost.rawValue,
|
||||||
if activity.activityType == UserActivityType.newPost.rawValue {
|
let str = activity.userInfo?["draftID"] as? String,
|
||||||
idStr = activity.userInfo?["draftID"] as? String
|
let uuid = UUID(uuidString: str) else {
|
||||||
} else {
|
|
||||||
idStr = activity.userInfo?["duckedDraftID"] as? String ?? activity.userInfo?["editedDraftID"] as? String
|
|
||||||
}
|
|
||||||
guard let idStr,
|
|
||||||
let uuid = UUID(uuidString: idStr) else {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return DraftsManager.shared.getBy(id: uuid)
|
return DraftsManager.shared.getBy(id: uuid)
|
||||||
|
|
|
@ -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: CollapseState) -> ConversationTableViewController
|
func conversation(mainStatusID: String, state: StatusState) -> 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: CollapseState) -> ConversationTableViewController {
|
func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController {
|
||||||
return ConversationTableViewController(for: mainStatusID, state: state, mastodonController: apiController)
|
return ConversationTableViewController(for: mainStatusID, state: state, mastodonController: apiController)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,11 +84,11 @@ extension TuskerNavigationDelegate {
|
||||||
self.selected(status: statusID, state: .unknown)
|
self.selected(status: statusID, state: .unknown)
|
||||||
}
|
}
|
||||||
|
|
||||||
func selected(status statusID: String, state: CollapseState) {
|
func selected(status statusID: String, state: StatusState) {
|
||||||
show(conversation(mainStatusID: statusID, state: state), sender: self)
|
show(conversation(mainStatusID: statusID, state: state), sender: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func compose(editing draft: Draft, animated: Bool = true) {
|
func compose(editing draft: Draft) {
|
||||||
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
|
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
|
||||||
let compose = UserActivityManager.editDraftActivity(id: draft.id, accountID: apiController.accountInfo!.id)
|
let compose = UserActivityManager.editDraftActivity(id: draft.id, accountID: apiController.accountInfo!.id)
|
||||||
let options = UIWindowScene.ActivationRequestOptions()
|
let options = UIWindowScene.ActivationRequestOptions()
|
||||||
|
@ -97,20 +97,20 @@ extension TuskerNavigationDelegate {
|
||||||
} else {
|
} else {
|
||||||
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
|
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
|
||||||
if #available(iOS 16.0, *),
|
if #available(iOS 16.0, *),
|
||||||
presentDuckable(compose, animated: animated) {
|
presentDuckable(compose) {
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
|
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
|
||||||
let nav = UINavigationController(rootViewController: compose)
|
let nav = UINavigationController(rootViewController: compose)
|
||||||
nav.presentationController?.delegate = compose
|
nav.presentationController?.delegate = compose
|
||||||
present(nav, animated: animated)
|
present(nav, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil, animated: Bool = true) {
|
func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil) {
|
||||||
let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct)
|
let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct)
|
||||||
compose(editing: draft, animated: animated)
|
compose(editing: draft)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView) -> LoadingLargeImageViewController {
|
func loadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView) -> LoadingLargeImageViewController {
|
||||||
|
@ -159,21 +159,21 @@ extension TuskerNavigationDelegate {
|
||||||
return UIActivityViewController(activityItems: [account.url, AccountActivityItemSource(account)], applicationActivities: nil)
|
return UIActivityViewController(activityItems: [account.url, AccountActivityItemSource(account)], applicationActivities: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func showMoreOptions(forStatus statusID: String, source: PopoverSource) {
|
func showMoreOptions(forStatus statusID: String, sourceView: UIView?) {
|
||||||
let vc = moreOptions(forStatus: statusID)
|
let vc = moreOptions(forStatus: statusID)
|
||||||
source.apply(to: vc)
|
vc.popoverPresentationController?.sourceView = sourceView
|
||||||
present(vc, animated: true)
|
present(vc, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func showMoreOptions(forURL url: URL, source: PopoverSource) {
|
func showMoreOptions(forURL url: URL, sourceView: UIView?) {
|
||||||
let vc = moreOptions(forURL: url)
|
let vc = moreOptions(forURL: url)
|
||||||
source.apply(to: vc)
|
vc.popoverPresentationController?.sourceView = sourceView
|
||||||
present(vc, animated: true)
|
present(vc, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func showMoreOptions(forAccount accountID: String, source: PopoverSource) {
|
func showMoreOptions(forAccount accountID: String, sourceView: UIView?) {
|
||||||
let vc = moreOptions(forAccount: accountID)
|
let vc = moreOptions(forAccount: accountID)
|
||||||
source.apply(to: vc)
|
vc.popoverPresentationController?.sourceView = sourceView
|
||||||
present(vc, animated: true)
|
present(vc, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -183,35 +183,8 @@ extension TuskerNavigationDelegate {
|
||||||
show(vc, sender: self)
|
show(vc, sender: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusActionAccountList(action: StatusActionAccountListViewController.ActionType, statusID: String, statusState state: CollapseState, accountIDs: [String]?) -> StatusActionAccountListViewController {
|
func statusActionAccountList(action: StatusActionAccountListViewController.ActionType, statusID: String, statusState state: StatusState, 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PopoverSource {
|
|
||||||
case none
|
|
||||||
case view(WeakHolder<UIView>)
|
|
||||||
case barButtonItem(WeakHolder<UIBarButtonItem>)
|
|
||||||
|
|
||||||
func apply(to viewController: UIViewController) {
|
|
||||||
if let popoverPresentationController = viewController.popoverPresentationController {
|
|
||||||
switch self {
|
|
||||||
case .none:
|
|
||||||
break
|
|
||||||
case .view(let view):
|
|
||||||
popoverPresentationController.sourceView = view.object
|
|
||||||
case .barButtonItem(let item):
|
|
||||||
popoverPresentationController.barButtonItem = item.object
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func view(_ view: UIView?) -> Self {
|
|
||||||
.view(WeakHolder(view))
|
|
||||||
}
|
|
||||||
|
|
||||||
static func barButtonItem(_ item: UIBarButtonItem?) -> Self {
|
|
||||||
.barButtonItem(WeakHolder(item))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -109,7 +109,7 @@ extension AccountTableViewCell: MenuPreviewProvider {
|
||||||
guard let mastodonController = mastodonController else { return nil }
|
guard let mastodonController = mastodonController else { return nil }
|
||||||
return (
|
return (
|
||||||
content: { ProfileViewController(accountID: self.accountID, mastodonController: mastodonController) },
|
content: { ProfileViewController(accountID: self.accountID, mastodonController: mastodonController) },
|
||||||
actions: { self.delegate?.actionsForProfile(accountID: self.accountID, source: .view(self.avatarImageView)) ?? [] }
|
actions: { self.delegate?.actionsForProfile(accountID: self.accountID, sourceView: self.avatarImageView) ?? [] }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,6 @@ import WebURL
|
||||||
import WebURLFoundationExtras
|
import WebURLFoundationExtras
|
||||||
|
|
||||||
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
||||||
private let dataDetectorsScheme = "x-apple-data-detectors"
|
|
||||||
|
|
||||||
class ContentTextView: LinkTextView, BaseEmojiLabel {
|
class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||||
|
|
||||||
|
@ -22,19 +21,14 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||||
weak var overrideMastodonController: MastodonController?
|
weak var overrideMastodonController: MastodonController?
|
||||||
var mastodonController: MastodonController? { overrideMastodonController ?? navigationDelegate?.apiController }
|
var mastodonController: MastodonController? { overrideMastodonController ?? navigationDelegate?.apiController }
|
||||||
|
|
||||||
private var htmlConverter = HTMLConverter()
|
var defaultFont: UIFont = .systemFont(ofSize: 17)
|
||||||
var defaultFont: UIFont {
|
var defaultColor: UIColor = .label
|
||||||
_read { yield htmlConverter.font }
|
var paragraphStyle: NSParagraphStyle = {
|
||||||
_modify { yield &htmlConverter.font }
|
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
|
||||||
var defaultColor: UIColor {
|
style.lineSpacing = 2
|
||||||
_read { yield htmlConverter.color }
|
return style
|
||||||
_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
|
||||||
|
|
||||||
|
@ -90,7 +84,99 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||||
|
|
||||||
// MARK: - HTML Parsing
|
// MARK: - HTML Parsing
|
||||||
func setTextFromHtml(_ html: String) {
|
func setTextFromHtml(_ html: String) {
|
||||||
self.attributedText = htmlConverter.convert(html)
|
let doc = try! SwiftSoup.parse(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
|
||||||
|
@ -112,8 +198,7 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||||
}
|
}
|
||||||
|
|
||||||
let location = recognizer.location(in: self)
|
let location = recognizer.location(in: self)
|
||||||
if let (link, range) = getLinkAtPoint(location),
|
if let (link, range) = getLinkAtPoint(location) {
|
||||||
link.scheme != dataDetectorsScheme {
|
|
||||||
let text = (self.text as NSString).substring(with: range)
|
let text = (self.text as NSString).substring(with: range)
|
||||||
handleLinkTapped(url: link, text: text)
|
handleLinkTapped(url: link, text: text)
|
||||||
}
|
}
|
||||||
|
@ -202,15 +287,9 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||||
|
|
||||||
extension ContentTextView: UITextViewDelegate {
|
extension ContentTextView: UITextViewDelegate {
|
||||||
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
|
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
|
||||||
|
// generally disable the text view's link interactions, we handle tapping links ourself with a gesture recognizer
|
||||||
// the builtin data detectors use the x-apple-data-detectors scheme, and we allow the text view to handle those itself
|
// the builtin data detectors use the x-apple-data-detectors scheme, and we allow the text view to handle those itself
|
||||||
if URL.scheme == dataDetectorsScheme {
|
return URL.scheme == "x-apple-data-detectors"
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
// otherwise, regular taps are handled by the gesture recognizer, but the accessibility interaction to select links with the rotor goes through here
|
|
||||||
// and this seems to be the only way of overriding what it does
|
|
||||||
handleLinkTapped(url: URL, text: (text as NSString).substring(with: characterRange))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -234,11 +313,11 @@ extension ContentTextView: UIContextMenuInteractionDelegate {
|
||||||
let text = (self.text as NSString).substring(with: range)
|
let text = (self.text as NSString).substring(with: range)
|
||||||
let actions: [UIMenuElement]
|
let actions: [UIMenuElement]
|
||||||
if let mention = self.getMention(for: link, text: text) {
|
if let mention = self.getMention(for: link, text: text) {
|
||||||
actions = self.actionsForProfile(accountID: mention.id, source: .view(self))
|
actions = self.actionsForProfile(accountID: mention.id, sourceView: self)
|
||||||
} else if let tag = self.getHashtag(for: link, text: text) {
|
} else if let tag = self.getHashtag(for: link, text: text) {
|
||||||
actions = self.actionsForHashtag(tag, source: .view(self))
|
actions = self.actionsForHashtag(tag, sourceView: self)
|
||||||
} else {
|
} else {
|
||||||
actions = self.actionsForURL(link, source: .view(self))
|
actions = self.actionsForURL(link, sourceView: self)
|
||||||
}
|
}
|
||||||
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
|
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,6 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import SwiftSoup
|
import SwiftSoup
|
||||||
import Sentry
|
|
||||||
|
|
||||||
class ActionNotificationGroupTableViewCell: UITableViewCell {
|
class ActionNotificationGroupTableViewCell: UITableViewCell {
|
||||||
|
|
||||||
|
@ -67,17 +66,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
|
||||||
self.group = group
|
self.group = group
|
||||||
|
|
||||||
guard let firstNotification = group.notifications.first else { fatalError() }
|
guard let firstNotification = group.notifications.first else { fatalError() }
|
||||||
guard let status = firstNotification.status else {
|
let status = firstNotification.status!
|
||||||
let crumb = Breadcrumb(level: .fatal, category: "notifications")
|
|
||||||
crumb.data = [
|
|
||||||
"id": firstNotification.id,
|
|
||||||
"type": firstNotification.kind.rawValue,
|
|
||||||
"created_at": firstNotification.createdAt.formatted(.iso8601),
|
|
||||||
"account": firstNotification.account.id,
|
|
||||||
]
|
|
||||||
SentrySDK.addBreadcrumb(crumb: crumb)
|
|
||||||
fatalError("missing status for favorite/reblog notification")
|
|
||||||
}
|
|
||||||
self.statusID = status.id
|
self.statusID = status.id
|
||||||
|
|
||||||
updateUIForPreferences()
|
updateUIForPreferences()
|
||||||
|
|
|
@ -214,7 +214,7 @@ extension FollowNotificationGroupTableViewCell: MenuPreviewProvider {
|
||||||
}
|
}
|
||||||
}, actions: {
|
}, actions: {
|
||||||
if accountIDs.count == 1 {
|
if accountIDs.count == 1 {
|
||||||
return self.delegate?.actionsForProfile(accountID: accountIDs.first!, source: .view(self)) ?? []
|
return self.delegate?.actionsForProfile(accountID: accountIDs.first!, sourceView: self) ?? []
|
||||||
} else {
|
} else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,7 +52,7 @@ class PollFinishedTableViewCell: UITableViewCell {
|
||||||
displayNameLabel.text = notification.account.displayName
|
displayNameLabel.text = notification.account.displayName
|
||||||
displayNameLabel.setEmojis(notification.account.emojis, identifier: notification.account.id)
|
displayNameLabel.setEmojis(notification.account.emojis, identifier: notification.account.id)
|
||||||
|
|
||||||
let doc = try! SwiftSoup.parseBodyFragment(status.content)
|
let doc = try! SwiftSoup.parse(status.content)
|
||||||
statusContentLabel.text = try! doc.text()
|
statusContentLabel.text = try! doc.text()
|
||||||
|
|
||||||
pollView.updateUI(status: status, poll: poll)
|
pollView.updateUI(status: status, poll: poll)
|
||||||
|
@ -114,7 +114,7 @@ extension PollFinishedTableViewCell: MenuPreviewProvider {
|
||||||
return (content: {
|
return (content: {
|
||||||
delegate.conversation(mainStatusID: statusID, state: .unknown)
|
delegate.conversation(mainStatusID: statusID, state: .unknown)
|
||||||
}, actions: {
|
}, actions: {
|
||||||
delegate.actionsForStatus(status, source: .view(self))
|
delegate.actionsForStatus(status, sourceView: self)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,114 +0,0 @@
|
||||||
//
|
|
||||||
// StatusUpdatedNotificationTableViewCell.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 11/27/22.
|
|
||||||
// Copyright © 2022 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import Pachyderm
|
|
||||||
import SwiftSoup
|
|
||||||
|
|
||||||
class StatusUpdatedNotificationTableViewCell: UITableViewCell {
|
|
||||||
|
|
||||||
weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)?
|
|
||||||
|
|
||||||
@IBOutlet weak var timestampLabel: UILabel!
|
|
||||||
@IBOutlet weak var displayNameLabel: EmojiLabel!
|
|
||||||
@IBOutlet weak var contentLabel: UILabel!
|
|
||||||
|
|
||||||
private var notification: Pachyderm.Notification?
|
|
||||||
private var updateTimestampWorkItem: DispatchWorkItem?
|
|
||||||
|
|
||||||
override func awakeFromNib() {
|
|
||||||
super.awakeFromNib()
|
|
||||||
|
|
||||||
timestampLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
|
|
||||||
.traits: [
|
|
||||||
UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue,
|
|
||||||
]
|
|
||||||
]), size: 0)
|
|
||||||
timestampLabel.adjustsFontForContentSizeCategory = true
|
|
||||||
|
|
||||||
displayNameLabel.font = .preferredFont(forTextStyle: .body).withTraits(.traitBold)!
|
|
||||||
displayNameLabel.adjustsFontForContentSizeCategory = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUI(notification: Pachyderm.Notification) {
|
|
||||||
guard notification.kind == .update,
|
|
||||||
let status = notification.status else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self.notification = notification
|
|
||||||
|
|
||||||
updateTimestamp()
|
|
||||||
|
|
||||||
displayNameLabel.text = notification.account.displayName
|
|
||||||
displayNameLabel.setEmojis(notification.account.emojis, identifier: notification.account.id)
|
|
||||||
|
|
||||||
let doc = try! SwiftSoup.parseBodyFragment(status.content)
|
|
||||||
contentLabel.text = try! doc.text()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateTimestamp() {
|
|
||||||
guard let notification else { return }
|
|
||||||
|
|
||||||
timestampLabel.text = notification.createdAt.timeAgoString()
|
|
||||||
|
|
||||||
let delay: DispatchTimeInterval?
|
|
||||||
switch notification.createdAt.timeAgo().1 {
|
|
||||||
case .second:
|
|
||||||
delay = .seconds(10)
|
|
||||||
case .minute:
|
|
||||||
delay = .seconds(60)
|
|
||||||
default:
|
|
||||||
delay = nil
|
|
||||||
}
|
|
||||||
if let delay = delay {
|
|
||||||
if updateTimestampWorkItem == nil {
|
|
||||||
updateTimestampWorkItem = DispatchWorkItem { [weak self] in
|
|
||||||
self?.updateTimestamp()
|
|
||||||
}
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: updateTimestampWorkItem!)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
updateTimestampWorkItem = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func prepareForReuse() {
|
|
||||||
super.prepareForReuse()
|
|
||||||
|
|
||||||
updateTimestampWorkItem?.cancel()
|
|
||||||
updateTimestampWorkItem = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension StatusUpdatedNotificationTableViewCell: SelectableTableViewCell {
|
|
||||||
func didSelectCell() {
|
|
||||||
guard let delegate,
|
|
||||||
let status = notification?.status else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let vc = delegate.conversation(mainStatusID: status.id, state: .unknown)
|
|
||||||
delegate.show(vc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension StatusUpdatedNotificationTableViewCell: MenuPreviewProvider {
|
|
||||||
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
|
|
||||||
guard let delegate,
|
|
||||||
let statusID = notification?.status?.id,
|
|
||||||
let status = delegate.apiController.persistentContainer.status(for: statusID) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return (content: {
|
|
||||||
delegate.conversation(mainStatusID: statusID, state: .unknown)
|
|
||||||
}, actions: {
|
|
||||||
delegate.actionsForStatus(status, source: .view(self))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,88 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
|
||||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
|
||||||
<dependencies>
|
|
||||||
<deployment identifier="iOS"/>
|
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
|
|
||||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
|
||||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
|
||||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
|
||||||
</dependencies>
|
|
||||||
<objects>
|
|
||||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
|
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
|
||||||
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" rowHeight="124" id="KGk-i7-Jjw" customClass="StatusUpdatedNotificationTableViewCell" customModule="Tusker" customModuleProvider="target">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="320" height="124"/>
|
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
|
||||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="320" height="124"/>
|
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
|
||||||
<subviews>
|
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="ZWN-ni-RLP">
|
|
||||||
<rect key="frame" x="74" y="11" width="230" height="102"/>
|
|
||||||
<subviews>
|
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="HLB-Iv-HTR">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="230" height="20.333333333333332"/>
|
|
||||||
<subviews>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="A post was edited" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="mBx-jm-6sU">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="206" height="20.333333333333332"/>
|
|
||||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
|
||||||
<nil key="textColor"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="252" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" text="2m" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="04d-Lt-yL5">
|
|
||||||
<rect key="frame" x="206" y="0.0" width="24" height="20.333333333333332"/>
|
|
||||||
<fontDescription key="fontDescription" type="system" weight="light" pointSize="17"/>
|
|
||||||
<color key="textColor" systemColor="secondaryLabelColor"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
</subviews>
|
|
||||||
</stackView>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" text="Person" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="F5w-FN-c33" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
|
|
||||||
<rect key="frame" x="0.0" y="24.333333333333336" width="230" height="20.333333333333336"/>
|
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
|
|
||||||
<color key="textColor" systemColor="secondaryLabelColor"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Content" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="A4y-Se-5rW">
|
|
||||||
<rect key="frame" x="0.0" y="48.666666666666657" width="230" height="53.333333333333343"/>
|
|
||||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
|
||||||
<color key="textColor" systemColor="secondaryLabelColor"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
</subviews>
|
|
||||||
</stackView>
|
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="pencil" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="3pZ-j9-PPP">
|
|
||||||
<rect key="frame" x="36" y="11" width="30" height="31"/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="width" constant="30" id="FOx-Ib-CH7"/>
|
|
||||||
<constraint firstAttribute="height" constant="30" id="UNQ-xp-O8B"/>
|
|
||||||
</constraints>
|
|
||||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/>
|
|
||||||
</imageView>
|
|
||||||
</subviews>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstItem="3pZ-j9-PPP" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="topMargin" id="6Aw-t0-5vM"/>
|
|
||||||
<constraint firstItem="ZWN-ni-RLP" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="topMargin" id="88f-jC-7Dk"/>
|
|
||||||
<constraint firstItem="ZWN-ni-RLP" firstAttribute="leading" secondItem="3pZ-j9-PPP" secondAttribute="trailing" constant="8" id="R68-9I-Bnh"/>
|
|
||||||
<constraint firstAttribute="bottomMargin" secondItem="ZWN-ni-RLP" secondAttribute="bottom" id="eCm-M2-qlS"/>
|
|
||||||
<constraint firstItem="ZWN-ni-RLP" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leadingMargin" constant="58" id="r84-Xe-N1F"/>
|
|
||||||
<constraint firstAttribute="trailingMargin" secondItem="ZWN-ni-RLP" secondAttribute="trailing" id="w0X-u7-BPp"/>
|
|
||||||
</constraints>
|
|
||||||
</tableViewCellContentView>
|
|
||||||
<viewLayoutGuide key="safeArea" id="njF-e1-oar"/>
|
|
||||||
<connections>
|
|
||||||
<outlet property="contentLabel" destination="A4y-Se-5rW" id="1j0-QT-bzy"/>
|
|
||||||
<outlet property="displayNameLabel" destination="F5w-FN-c33" id="q3K-Od-YxV"/>
|
|
||||||
<outlet property="timestampLabel" destination="04d-Lt-yL5" id="VeH-73-9Gh"/>
|
|
||||||
</connections>
|
|
||||||
<point key="canvasLocation" x="74.809160305343511" y="16.901408450704228"/>
|
|
||||||
</tableViewCell>
|
|
||||||
</objects>
|
|
||||||
<resources>
|
|
||||||
<image name="pencil" catalog="system" width="128" height="113"/>
|
|
||||||
<systemColor name="secondaryLabelColor">
|
|
||||||
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
|
|
||||||
</systemColor>
|
|
||||||
</resources>
|
|
||||||
</document>
|
|
|
@ -34,8 +34,7 @@ class ProfileHeaderView: UIView {
|
||||||
@IBOutlet weak var moreButton: VisualEffectImageButton!
|
@IBOutlet weak var moreButton: VisualEffectImageButton!
|
||||||
@IBOutlet weak var displayNameLabel: EmojiLabel!
|
@IBOutlet weak var displayNameLabel: EmojiLabel!
|
||||||
@IBOutlet weak var usernameLabel: UILabel!
|
@IBOutlet weak var usernameLabel: UILabel!
|
||||||
@IBOutlet weak var lockImageView: UIImageView!
|
@IBOutlet weak var followsYouLabel: UILabel!
|
||||||
@IBOutlet weak var relationshipLabel: UILabel!
|
|
||||||
@IBOutlet weak var noteTextView: StatusContentTextView!
|
@IBOutlet weak var noteTextView: StatusContentTextView!
|
||||||
@IBOutlet weak var fieldsView: ProfileFieldsView!
|
@IBOutlet weak var fieldsView: ProfileFieldsView!
|
||||||
@IBOutlet weak var pagesSegmentedControl: UISegmentedControl!
|
@IBOutlet weak var pagesSegmentedControl: UISegmentedControl!
|
||||||
|
@ -77,8 +76,8 @@ class ProfileHeaderView: UIView {
|
||||||
usernameLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .light))
|
usernameLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .light))
|
||||||
usernameLabel.adjustsFontForContentSizeCategory = true
|
usernameLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
|
||||||
relationshipLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14))
|
followsYouLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14))
|
||||||
relationshipLabel.adjustsFontForContentSizeCategory = true
|
followsYouLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
|
||||||
noteTextView.defaultFont = .preferredFont(forTextStyle: .body)
|
noteTextView.defaultFont = .preferredFont(forTextStyle: .body)
|
||||||
noteTextView.adjustsFontForContentSizeCategory = true
|
noteTextView.adjustsFontForContentSizeCategory = true
|
||||||
|
@ -118,11 +117,10 @@ class ProfileHeaderView: UIView {
|
||||||
updateUIForPreferences()
|
updateUIForPreferences()
|
||||||
|
|
||||||
usernameLabel.text = "@\(account.acct)"
|
usernameLabel.text = "@\(account.acct)"
|
||||||
lockImageView.isHidden = !account.locked
|
|
||||||
|
|
||||||
updateImages(account: account)
|
updateImages(account: account)
|
||||||
|
|
||||||
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForProfile(accountID: accountID, source: .view(moreButton)) ?? [])
|
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForProfile(accountID: accountID, sourceView: moreButton) ?? [])
|
||||||
|
|
||||||
noteTextView.navigationDelegate = delegate
|
noteTextView.navigationDelegate = delegate
|
||||||
noteTextView.setTextFromHtml(account.note)
|
noteTextView.setTextFromHtml(account.note)
|
||||||
|
@ -150,7 +148,6 @@ class ProfileHeaderView: UIView {
|
||||||
accessibilityElements = [
|
accessibilityElements = [
|
||||||
displayNameLabel!,
|
displayNameLabel!,
|
||||||
usernameLabel!,
|
usernameLabel!,
|
||||||
relationshipLabel!,
|
|
||||||
noteTextView!,
|
noteTextView!,
|
||||||
fieldsView!,
|
fieldsView!,
|
||||||
moreButton!,
|
moreButton!,
|
||||||
|
@ -164,22 +161,7 @@ class ProfileHeaderView: UIView {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var relationshipStr: String?
|
followsYouLabel.isHidden = !relationship.followedBy
|
||||||
if relationship.following && relationship.followedBy {
|
|
||||||
relationshipStr = "You follow each other"
|
|
||||||
} else if relationship.following {
|
|
||||||
relationshipStr = "You follow"
|
|
||||||
} else if relationship.followedBy {
|
|
||||||
relationshipStr = "Follows you"
|
|
||||||
} else if relationship.blocking {
|
|
||||||
relationshipStr = "You block"
|
|
||||||
}
|
|
||||||
if let relationshipStr {
|
|
||||||
relationshipLabel.text = relationshipStr
|
|
||||||
relationshipLabel.isHidden = false
|
|
||||||
} else {
|
|
||||||
relationshipLabel.isHidden = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func updateUIForPreferences() {
|
@objc private func updateUIForPreferences() {
|
||||||
|
|
|
@ -40,7 +40,7 @@
|
||||||
</constraints>
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Display name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="10" adjustsLetterSpacingToFitWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="vcl-Gl-kXl" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Display name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="10" adjustsLetterSpacingToFitWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="vcl-Gl-kXl" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
|
||||||
<rect key="frame" x="144" y="206" width="254" height="29"/>
|
<rect key="frame" x="144" y="206" width="254" height="32"/>
|
||||||
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="24"/>
|
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="24"/>
|
||||||
<nil key="textColor"/>
|
<nil key="textColor"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
|
@ -60,7 +60,7 @@
|
||||||
</userDefinedRuntimeAttributes>
|
</userDefinedRuntimeAttributes>
|
||||||
</view>
|
</view>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="u4P-3i-gEq">
|
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="u4P-3i-gEq">
|
||||||
<rect key="frame" x="16" y="266" width="398" height="596"/>
|
<rect key="frame" x="16" y="419" width="398" height="443"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Follows you" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="UF8-nI-KVj">
|
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Follows you" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="UF8-nI-KVj">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="75.5" height="0.0"/>
|
<rect key="frame" x="0.0" y="0.0" width="75.5" height="0.0"/>
|
||||||
|
@ -83,7 +83,7 @@
|
||||||
</constraints>
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
<segmentedControl opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="top" segmentControlStyle="plain" selectedSegmentIndex="0" translatesAutoresizingMaskIntoConstraints="NO" id="n1M-vM-Cj0">
|
<segmentedControl opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="top" segmentControlStyle="plain" selectedSegmentIndex="0" translatesAutoresizingMaskIntoConstraints="NO" id="n1M-vM-Cj0">
|
||||||
<rect key="frame" x="0.0" y="403.5" width="382" height="185"/>
|
<rect key="frame" x="0.0" y="403.5" width="382" height="32"/>
|
||||||
<segments>
|
<segments>
|
||||||
<segment title="Posts"/>
|
<segment title="Posts"/>
|
||||||
<segment title="Posts and Replies"/>
|
<segment title="Posts and Replies"/>
|
||||||
|
@ -94,7 +94,7 @@
|
||||||
</connections>
|
</connections>
|
||||||
</segmentedControl>
|
</segmentedControl>
|
||||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5ja-fK-Fqz">
|
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5ja-fK-Fqz">
|
||||||
<rect key="frame" x="0.0" y="595.5" width="398" height="0.5"/>
|
<rect key="frame" x="0.0" y="442.5" width="398" height="0.5"/>
|
||||||
<color key="backgroundColor" systemColor="separatorColor"/>
|
<color key="backgroundColor" systemColor="separatorColor"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="height" constant="0.5" id="VwS-gV-q8M"/>
|
<constraint firstAttribute="height" constant="0.5" id="VwS-gV-q8M"/>
|
||||||
|
@ -108,57 +108,46 @@
|
||||||
<constraint firstItem="1O8-2P-Gbf" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" constant="-16" id="hnA-3G-B9B"/>
|
<constraint firstItem="1O8-2P-Gbf" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" constant="-16" id="hnA-3G-B9B"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
</stackView>
|
</stackView>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="jwU-EH-hmC">
|
|
||||||
<rect key="frame" x="144" y="235" width="103.5" height="23"/>
|
|
||||||
<subviews>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="@username" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1C3-Pd-QiL">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="@username" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1C3-Pd-QiL">
|
||||||
<rect key="frame" x="0.0" y="2.5" width="81" height="18"/>
|
<rect key="frame" x="144" y="238" width="254" height="18"/>
|
||||||
<fontDescription key="fontDescription" type="system" weight="light" pointSize="15"/>
|
<fontDescription key="fontDescription" type="system" weight="light" pointSize="15"/>
|
||||||
<color key="textColor" systemColor="secondaryLabelColor"/>
|
<color key="textColor" systemColor="secondaryLabelColor"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" image="lock.fill" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="KNY-GD-beC">
|
|
||||||
<rect key="frame" x="85" y="1.5" width="18.5" height="19.5"/>
|
|
||||||
<color key="tintColor" systemColor="secondaryLabelColor"/>
|
|
||||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" weight="light"/>
|
|
||||||
</imageView>
|
|
||||||
</subviews>
|
|
||||||
</stackView>
|
|
||||||
</subviews>
|
</subviews>
|
||||||
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
|
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
|
||||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="jwU-EH-hmC" secondAttribute="trailing" constant="16" id="0VP-ri-Io5"/>
|
<constraint firstItem="1C3-Pd-QiL" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="23a-no-Gjj"/>
|
||||||
<constraint firstItem="dgG-dR-lSv" firstAttribute="top" secondItem="vUN-kp-3ea" secondAttribute="top" id="6Q0-q5-Ju6"/>
|
<constraint firstItem="dgG-dR-lSv" firstAttribute="top" secondItem="vUN-kp-3ea" secondAttribute="top" id="6Q0-q5-Ju6"/>
|
||||||
<constraint firstItem="wT9-2J-uSY" firstAttribute="centerY" secondItem="dgG-dR-lSv" secondAttribute="bottom" id="7gb-T3-Xe7"/>
|
<constraint firstItem="wT9-2J-uSY" firstAttribute="centerY" secondItem="dgG-dR-lSv" secondAttribute="bottom" id="7gb-T3-Xe7"/>
|
||||||
<constraint firstItem="vcl-Gl-kXl" firstAttribute="top" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="8" symbolic="YES" id="7ss-Mf-YYH"/>
|
<constraint firstItem="vcl-Gl-kXl" firstAttribute="top" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="8" symbolic="YES" id="7ss-Mf-YYH"/>
|
||||||
<constraint firstItem="vcl-Gl-kXl" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="8ho-WU-MxW"/>
|
<constraint firstItem="vcl-Gl-kXl" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="8ho-WU-MxW"/>
|
||||||
<constraint firstItem="jwU-EH-hmC" firstAttribute="top" secondItem="vcl-Gl-kXl" secondAttribute="bottom" id="9lx-Fn-M0U"/>
|
|
||||||
<constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="u4P-3i-gEq" secondAttribute="bottom" id="9zc-N2-mfI"/>
|
<constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="u4P-3i-gEq" secondAttribute="bottom" id="9zc-N2-mfI"/>
|
||||||
<constraint firstItem="bRJ-Xf-kc9" firstAttribute="bottom" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="-8" id="AXS-bG-20Q"/>
|
<constraint firstItem="bRJ-Xf-kc9" firstAttribute="bottom" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="-8" id="AXS-bG-20Q"/>
|
||||||
<constraint firstItem="jwU-EH-hmC" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="TkY-oK-if4" secondAttribute="bottom" id="HBR-rg-RxO"/>
|
<constraint firstItem="1C3-Pd-QiL" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="TkY-oK-if4" secondAttribute="bottom" id="OpB-YM-gyu"/>
|
||||||
<constraint firstItem="dgG-dR-lSv" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" id="VD1-yc-KSa"/>
|
<constraint firstItem="dgG-dR-lSv" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" id="VD1-yc-KSa"/>
|
||||||
<constraint firstItem="wT9-2J-uSY" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="WNS-AR-ff2"/>
|
<constraint firstItem="wT9-2J-uSY" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="WNS-AR-ff2"/>
|
||||||
<constraint firstItem="bRJ-Xf-kc9" firstAttribute="trailing" secondItem="vUN-kp-3ea" secondAttribute="trailing" constant="-8" id="ZB4-ys-9zP"/>
|
<constraint firstItem="bRJ-Xf-kc9" firstAttribute="trailing" secondItem="vUN-kp-3ea" secondAttribute="trailing" constant="-8" id="ZB4-ys-9zP"/>
|
||||||
|
<constraint firstItem="1C3-Pd-QiL" firstAttribute="top" secondItem="vcl-Gl-kXl" secondAttribute="bottom" id="d0z-X6-Sig"/>
|
||||||
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="vcl-Gl-kXl" secondAttribute="trailing" constant="16" id="e38-Od-kPg"/>
|
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="vcl-Gl-kXl" secondAttribute="trailing" constant="16" id="e38-Od-kPg"/>
|
||||||
<constraint firstItem="u4P-3i-gEq" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="hgl-UR-o3W"/>
|
<constraint firstItem="u4P-3i-gEq" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="hgl-UR-o3W"/>
|
||||||
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="dgG-dR-lSv" secondAttribute="trailing" id="j0d-hY-815"/>
|
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="dgG-dR-lSv" secondAttribute="trailing" id="j0d-hY-815"/>
|
||||||
<constraint firstItem="jwU-EH-hmC" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="oGR-6M-gdd"/>
|
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="1C3-Pd-QiL" secondAttribute="trailing" constant="16" id="pcH-vi-2zH"/>
|
||||||
<constraint firstAttribute="trailing" secondItem="u4P-3i-gEq" secondAttribute="trailing" id="ph6-NT-A02"/>
|
<constraint firstAttribute="trailing" secondItem="u4P-3i-gEq" secondAttribute="trailing" id="ph6-NT-A02"/>
|
||||||
<constraint firstItem="u4P-3i-gEq" firstAttribute="top" secondItem="wT9-2J-uSY" secondAttribute="bottom" priority="999" constant="8" id="tKQ-6d-Z55"/>
|
<constraint firstItem="u4P-3i-gEq" firstAttribute="top" relation="greaterThanOrEqual" secondItem="wT9-2J-uSY" secondAttribute="bottom" constant="8" id="tKQ-6d-Z55"/>
|
||||||
<constraint firstItem="u4P-3i-gEq" firstAttribute="top" secondItem="jwU-EH-hmC" secondAttribute="bottom" priority="999" constant="8" id="xDD-rx-gC0"/>
|
<constraint firstItem="u4P-3i-gEq" firstAttribute="top" relation="greaterThanOrEqual" secondItem="vcl-Gl-kXl" secondAttribute="bottom" constant="8" id="xDD-rx-gC0"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
<connections>
|
<connections>
|
||||||
<outlet property="avatarContainerView" destination="wT9-2J-uSY" id="yEm-h7-tfq"/>
|
<outlet property="avatarContainerView" destination="wT9-2J-uSY" id="yEm-h7-tfq"/>
|
||||||
<outlet property="avatarImageView" destination="TkY-oK-if4" id="bSJ-7z-j4w"/>
|
<outlet property="avatarImageView" destination="TkY-oK-if4" id="bSJ-7z-j4w"/>
|
||||||
<outlet property="displayNameLabel" destination="vcl-Gl-kXl" id="64n-a9-my0"/>
|
<outlet property="displayNameLabel" destination="vcl-Gl-kXl" id="64n-a9-my0"/>
|
||||||
<outlet property="fieldsView" destination="vKC-m1-Sbs" id="FeE-jh-lYH"/>
|
<outlet property="fieldsView" destination="vKC-m1-Sbs" id="FeE-jh-lYH"/>
|
||||||
|
<outlet property="followsYouLabel" destination="UF8-nI-KVj" id="dTe-DQ-eJV"/>
|
||||||
<outlet property="headerImageView" destination="dgG-dR-lSv" id="HXT-v4-2iX"/>
|
<outlet property="headerImageView" destination="dgG-dR-lSv" id="HXT-v4-2iX"/>
|
||||||
<outlet property="lockImageView" destination="KNY-GD-beC" id="9EJ-iM-Eos"/>
|
|
||||||
<outlet property="moreButton" destination="bRJ-Xf-kc9" id="zIN-pz-L7y"/>
|
<outlet property="moreButton" destination="bRJ-Xf-kc9" id="zIN-pz-L7y"/>
|
||||||
<outlet property="noteTextView" destination="1O8-2P-Gbf" id="yss-zZ-uQ5"/>
|
<outlet property="noteTextView" destination="1O8-2P-Gbf" id="yss-zZ-uQ5"/>
|
||||||
<outlet property="pagesSegmentedControl" destination="n1M-vM-Cj0" id="TCU-ku-YZN"/>
|
<outlet property="pagesSegmentedControl" destination="n1M-vM-Cj0" id="TCU-ku-YZN"/>
|
||||||
<outlet property="relationshipLabel" destination="UF8-nI-KVj" id="dTe-DQ-eJV"/>
|
|
||||||
<outlet property="usernameLabel" destination="1C3-Pd-QiL" id="57b-LQ-3pM"/>
|
<outlet property="usernameLabel" destination="1C3-Pd-QiL" id="57b-LQ-3pM"/>
|
||||||
</connections>
|
</connections>
|
||||||
<point key="canvasLocation" x="-590" y="117"/>
|
<point key="canvasLocation" x="-590" y="117"/>
|
||||||
|
@ -166,7 +155,6 @@
|
||||||
</objects>
|
</objects>
|
||||||
<resources>
|
<resources>
|
||||||
<image name="ellipsis" catalog="system" width="128" height="37"/>
|
<image name="ellipsis" catalog="system" width="128" height="37"/>
|
||||||
<image name="lock.fill" catalog="system" width="125" height="128"/>
|
|
||||||
<systemColor name="labelColor">
|
<systemColor name="labelColor">
|
||||||
<color red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
</systemColor>
|
</systemColor>
|
||||||
|
|
|
@ -57,7 +57,7 @@ class BaseStatusTableViewCell: UITableViewCell {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private(set) var statusState: CollapseState!
|
private(set) var statusState: StatusState!
|
||||||
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: CollapseState) {
|
final func updateUI(statusID: String, state: StatusState) {
|
||||||
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: CollapseState) {
|
func doUpdateUI(status: StatusMO, state: StatusState) {
|
||||||
self.statusState = state
|
self.statusState = state
|
||||||
|
|
||||||
let account = status.account
|
let account = status.account
|
||||||
|
@ -182,8 +182,8 @@ class BaseStatusTableViewCell: UITableViewCell {
|
||||||
updateStatusIconsForPreferences(status)
|
updateStatusIconsForPreferences(status)
|
||||||
|
|
||||||
if state.unknown {
|
if state.unknown {
|
||||||
// for some reason the height here can't be computed correctly, so we fallback to the old hack of just considering raw length
|
layoutIfNeeded()
|
||||||
state.resolveFor(status: status, height: 0, textLength: contentTextView.attributedText.length)
|
state.resolveFor(status: status, height: contentTextView.bounds.height)
|
||||||
if state.collapsible! && showStatusAutomatically {
|
if state.collapsible! && showStatusAutomatically {
|
||||||
state.collapsed = false
|
state.collapsed = false
|
||||||
}
|
}
|
||||||
|
@ -209,7 +209,7 @@ class BaseStatusTableViewCell: UITableViewCell {
|
||||||
|
|
||||||
// keep menu in sync with changed states e.g. bookmarked, muted
|
// keep menu in sync with changed states e.g. bookmarked, muted
|
||||||
// do not include reply action here, because the cell already contains a button for it
|
// do not include reply action here, because the cell already contains a button for it
|
||||||
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, source: .view(moreButton), includeStatusButtonActions: false) ?? [])
|
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, sourceView: moreButton, includeStatusButtonActions: false) ?? [])
|
||||||
|
|
||||||
pollView.isHidden = status.poll == nil
|
pollView.isHidden = status.poll == nil
|
||||||
pollView.mastodonController = mastodonController
|
pollView.mastodonController = mastodonController
|
||||||
|
@ -336,6 +336,7 @@ class BaseStatusTableViewCell: UITableViewCell {
|
||||||
super.prepareForReuse()
|
super.prepareForReuse()
|
||||||
|
|
||||||
avatarRequest?.cancel()
|
avatarRequest?.cancel()
|
||||||
|
attachmentsView.attachmentViews.allObjects.forEach { $0.removeFromSuperview() }
|
||||||
showStatusAutomatically = false
|
showStatusAutomatically = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -408,7 +409,7 @@ class BaseStatusTableViewCell: UITableViewCell {
|
||||||
}
|
}
|
||||||
|
|
||||||
@IBAction func morePressed() {
|
@IBAction func morePressed() {
|
||||||
delegate?.showMoreOptions(forStatus: statusID, source: .view(moreButton))
|
delegate?.showMoreOptions(forStatus: statusID, sourceView: moreButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func accountPressed() {
|
@objc func accountPressed() {
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue