Compare commits
110 Commits
0e5aab75df
...
4719342a06
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 4719342a06 | |
Shadowfacts | 6df5f7fb08 | |
Shadowfacts | 02135aa0de | |
Shadowfacts | be5a4c03a6 | |
Shadowfacts | 2c1ba7926e | |
Shadowfacts | 911e66a159 | |
Shadowfacts | ab4bcfa50f | |
Shadowfacts | b94bfca406 | |
Shadowfacts | 7999ecafd0 | |
Shadowfacts | 1c6e464a4c | |
Shadowfacts | acd01a81cc | |
Shadowfacts | 8ac3deb55a | |
Shadowfacts | 5e9cc430c6 | |
Shadowfacts | 0b6ef6517b | |
Shadowfacts | 34a01094f7 | |
Shadowfacts | 95b215c6b5 | |
Shadowfacts | e21dceb3b3 | |
Shadowfacts | 9534f19262 | |
Shadowfacts | e44ae29775 | |
Shadowfacts | a5b30c4243 | |
Shadowfacts | 479ca23e00 | |
Shadowfacts | 5b03e0cf12 | |
Shadowfacts | 7c4bbfd730 | |
Shadowfacts | e19a6528ad | |
Shadowfacts | f5110c773a | |
Shadowfacts | fe1db72f19 | |
Shadowfacts | b4ddb8f533 | |
Shadowfacts | 9a4ddfea3f | |
Shadowfacts | dd8a196630 | |
Shadowfacts | 3da7aacb35 | |
Shadowfacts | 39c8162931 | |
Shadowfacts | fe95cb9e1a | |
Shadowfacts | ec2d510be2 | |
Shadowfacts | 262aadf807 | |
Shadowfacts | 9dce94c014 | |
Shadowfacts | d008b882cb | |
Shadowfacts | 3d13df87f0 | |
Shadowfacts | f0582739cc | |
Shadowfacts | 4c82b1a341 | |
Shadowfacts | b55a96d649 | |
Shadowfacts | 77ac8cbe40 | |
Shadowfacts | e026c9a6c6 | |
Shadowfacts | 3937dde2bf | |
Shadowfacts | 95ebca04d2 | |
Shadowfacts | 0986fa285e | |
Shadowfacts | 1cd3e6adf9 | |
Shadowfacts | 722b81dad9 | |
Shadowfacts | 059f7307b3 | |
Shadowfacts | ee20c95a5d | |
Shadowfacts | be81ffb61f | |
Shadowfacts | 08e0c3769f | |
Shadowfacts | 6d7c9fd553 | |
Shadowfacts | 9b04b75949 | |
Shadowfacts | 273b74ddfb | |
Shadowfacts | ae055f1ffd | |
Shadowfacts | eef9b96a1a | |
Shadowfacts | 29aed65b99 | |
Shadowfacts | 090746f292 | |
Shadowfacts | af300a3559 | |
Shadowfacts | 79eb23ef5d | |
Shadowfacts | 60565f9625 | |
Shadowfacts | 70bedf17a8 | |
Shadowfacts | 392e51eb3e | |
Shadowfacts | 86d5a73c85 | |
Shadowfacts | eaefa366b7 | |
Shadowfacts | 79b23127e9 | |
Shadowfacts | f9b85c87b4 | |
Shadowfacts | 260bedcf10 | |
Shadowfacts | fe09c5e522 | |
Shadowfacts | 985d30a401 | |
Shadowfacts | 794594805c | |
Shadowfacts | 1c708732f2 | |
Shadowfacts | db30471011 | |
Shadowfacts | 2825345c7e | |
Shadowfacts | f3d01c47c3 | |
Shadowfacts | caab5e357a | |
Shadowfacts | 2916d7a72d | |
Shadowfacts | d190636fbd | |
Shadowfacts | 4e4701ead5 | |
Shadowfacts | b07efc150c | |
Shadowfacts | 19fa12391d | |
Shadowfacts | c55ea2e005 | |
Shadowfacts | 47dc00ab8f | |
Shadowfacts | fdcdbced38 | |
Shadowfacts | e70a84274e | |
Shadowfacts | 641ab765a7 | |
Shadowfacts | 986fc5b833 | |
Shadowfacts | cf5b97d9c8 | |
Shadowfacts | 7f0fd119c5 | |
Shadowfacts | b2c7735256 | |
Shadowfacts | 1d815d6cd6 | |
Shadowfacts | f86d3a0ed1 | |
Shadowfacts | 864fd77ecc | |
Shadowfacts | 78da04162f | |
Shadowfacts | 40a742139b | |
Shadowfacts | 8bbc572fa7 | |
Shadowfacts | 2a8e970738 | |
Shadowfacts | 3abb5972b9 | |
Shadowfacts | 0c06d91f6b | |
Shadowfacts | 6cf6db6a8d | |
Shadowfacts | fb11e36467 | |
Shadowfacts | 0fa87e9177 | |
Shadowfacts | 5cb84e271a | |
Shadowfacts | 50f1a9a7de | |
Shadowfacts | 154fc7cd02 | |
Shadowfacts | 01d765fa45 | |
Shadowfacts | 04aad1252a | |
Shadowfacts | 43779e42df | |
Shadowfacts | a5a2cd147e | |
Shadowfacts | 0e91fc239d |
|
@ -1,6 +1,3 @@
|
|||
[submodule "SwiftSoup"]
|
||||
path = SwiftSoup
|
||||
url = git://github.com/scinfu/SwiftSoup.git
|
||||
[submodule "Cache"]
|
||||
path = Cache
|
||||
url = git@github.com:hyperoslo/Cache.git
|
||||
|
|
72
CHANGELOG.md
72
CHANGELOG.md
|
@ -1,5 +1,77 @@
|
|||
# Changelog
|
||||
|
||||
## 2020.1 (9)
|
||||
The marquee feature of this build is the new and improved Compose screen. It's been rewritten to use SwiftUI, is significantly more resilient to data loss, and now shows the toolbar when the main text field is not focused. It also turns out Apple is surprise-releasing iOS 14 very soon (or possibly already has, depending when you're reading this). For those who were not already on the beta train, iOS 14 brings a number of new features including a sidebar on iPadOS and lots and lots of context menus (a home screen widget is coming Soon™).
|
||||
|
||||
Known Issues:
|
||||
- Pasting images to create attachments when composing a post is not currently supported due to an iOS bug (#109)
|
||||
- Full-size previews do not display in context menus for attachments on the Compose screen due to an iOS issue (#110)
|
||||
|
||||
Features/Improvements:
|
||||
- Rewrite Compose screen using SwiftUI
|
||||
- Prevent draft posts being lost if the app crahes or is killed by the system while composing
|
||||
- Show toolbar while post content is not being edited
|
||||
- Save post visibility in drafts
|
||||
- Move Draw Something action out of the context menu
|
||||
- iOS 14: Use context menus for setting post visibility
|
||||
- Show BlurHash previews for attachments on Mastodon
|
||||
- Add Expand All Content Warnings preference (Preferences -> Behavior)
|
||||
- Add Collapse Long Posts preference (Preferences -> Behavior)
|
||||
- Improve image gallery opening animation
|
||||
- Use fade in/out animations for opening/closing gallery and attachment picker when the Reduce Motion system setting is enabled
|
||||
- iOS 14: Also requires the "Prefer Cross Fade" setting be enabled
|
||||
- Slightly reduce default status font sizes
|
||||
- Add "Direct Message" context menu action to Compose button on profile screen
|
||||
- Allow viewing attachments and navigating through posts/accounts on instance public timelines
|
||||
|
||||
Bugfixes:
|
||||
- Fix errors when uploading attachments not displaying
|
||||
- Fix attachments not posting in the correct, user-specified order
|
||||
- Fix accounts displaying with outdated information (avatars, display names, etc.)
|
||||
- Fix Compose not showing button on profile screen
|
||||
- Fix navigation title not being set on profile screen
|
||||
- Fix follow notifications not showing names for users without display names set
|
||||
- iPadOS 14: Fix crash when resizing app in split view mode
|
||||
|
||||
## 2020.1 (8)
|
||||
This is just an emergency build to fix crashes on iOS 13 when selecting attachments. The changelog of the previous build is included below.
|
||||
|
||||
Features/Improvements:
|
||||
- Enlarge tap targets on status reply/favorite/reblog/more buttons
|
||||
- Disable automatic GIF playback when Low Power Mode is enabled
|
||||
- Show custom emoji in user profile field names
|
||||
|
||||
Bugfixes:
|
||||
- Fix crash when attempting to add attachments on iOS 13
|
||||
- Fix potential crashes
|
||||
|
||||
## 2020.1 (7)
|
||||
This is the first update since WWDC and the introduction of iOS 14. As such, most of the focus has been on fixing iOS 14-specific problems. However, there are still a couple new features, both for those on the iOS 14 beta and those not.
|
||||
|
||||
Features/Improvements:
|
||||
- Add toggle between Posts, Posts and Replies, and Media on user profiles
|
||||
- Remove 'Show Replies in Profiles' preference
|
||||
- Limit link preview animation to only link text
|
||||
- Add additional context menu actions for statuses, accounts, and hashtags
|
||||
- Add semi-translucent background to image descriptions, so they're legible against light images
|
||||
- iPadOS 14: Add sidebar
|
||||
- When using multitasking on iPad and switching in and out of "compact" mode, the active tab as well as the navigation history for all tabs will be transferred between the sidebar and tab bar modes.
|
||||
- iOS 14: Use context menus on status/account '...' buttons
|
||||
- iOS 14: Replace 'More' status swipe action with 'Share'
|
||||
|
||||
Bugfixes:
|
||||
- Fix crash when attempting to change post visibility on iPad
|
||||
- Fix attachment view corners not being rounded
|
||||
- Fix crash when viewing instance public timelines
|
||||
- Fix Preferences button not appearing on My Profile tab
|
||||
- Fix tapping current tab bar item not scrolling to top
|
||||
- Fix crash showing audio attachments on Mastodon
|
||||
- Fix timeline refreshing forever
|
||||
- Set app category (fixes usage not being categorized correctly under Screen Time)
|
||||
- iOS 14: Fix crash when searching for instances
|
||||
- iOS 14: Fix crash when displaying accounts with no pinned posts
|
||||
- iOS 14: Fix crash when displaying search results
|
||||
|
||||
## 2020.1 (6)
|
||||
This is the pre-WWDC update with lots of bugfixes and some small features. There will likely be another build this week to fix any pressing issues that arise from iOS 14.
|
||||
|
||||
|
|
2
Gifu
2
Gifu
|
@ -1 +1 @@
|
|||
Subproject commit ed572f53ce58b8e23499abeb3a926033cbe480f7
|
||||
Subproject commit 9b1a6461aa3b5f66cb0ed3a50c5523db0b4fb007
|
|
@ -26,7 +26,7 @@ public class Client {
|
|||
|
||||
public var timeoutInterval: TimeInterval = 60
|
||||
|
||||
lazy var decoder: JSONDecoder = {
|
||||
static let decoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
|
||||
|
@ -36,6 +36,16 @@ public class Client {
|
|||
return decoder
|
||||
}()
|
||||
|
||||
static let encoder: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
|
||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
encoder.dateEncodingStrategy = .formatted(formatter)
|
||||
return encoder
|
||||
}()
|
||||
|
||||
public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
|
||||
self.baseURL = baseURL
|
||||
self.accessToken = accessToken
|
||||
|
@ -50,22 +60,22 @@ public class Client {
|
|||
|
||||
let task = session.dataTask(with: request) { data, response, error in
|
||||
if let error = error {
|
||||
completion(.failure(error))
|
||||
completion(.failure(.networkError(error)))
|
||||
return
|
||||
}
|
||||
guard let data = data,
|
||||
let response = response as? HTTPURLResponse else {
|
||||
completion(.failure(Error.invalidResponse))
|
||||
completion(.failure(.invalidResponse))
|
||||
return
|
||||
}
|
||||
guard response.statusCode == 200 else {
|
||||
let mastodonError = try? self.decoder.decode(MastodonError.self, from: data)
|
||||
let error = mastodonError.flatMap { Error.mastodonError($0.description) } ?? Error.unknownError
|
||||
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
|
||||
let error: Error = mastodonError.flatMap { .mastodonError($0.description) } ?? .unexpectedStatus(response.statusCode)
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
guard let result = try? self.decoder.decode(Result.self, from: data) else {
|
||||
completion(.failure(Error.invalidModel))
|
||||
guard let result = try? Client.decoder.decode(Result.self, from: data) else {
|
||||
completion(.failure(.invalidModel))
|
||||
return
|
||||
}
|
||||
let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
|
||||
|
@ -92,7 +102,7 @@ public class Client {
|
|||
|
||||
// MARK: - Authorization
|
||||
public func registerApp(name: String, redirectURI: String, scopes: [Scope], website: URL? = nil, completion: @escaping Callback<RegisteredApplication>) {
|
||||
let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: .parameters([
|
||||
let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: ParametersBody([
|
||||
"client_name" => name,
|
||||
"redirect_uris" => redirectURI,
|
||||
"scopes" => scopes.scopeString,
|
||||
|
@ -109,7 +119,7 @@ public class Client {
|
|||
}
|
||||
|
||||
public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) {
|
||||
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: .parameters([
|
||||
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: ParametersBody([
|
||||
"client_id" => clientID,
|
||||
"client_secret" => clientSecret,
|
||||
"grant_type" => "authorization_code",
|
||||
|
@ -168,13 +178,13 @@ public class Client {
|
|||
}
|
||||
|
||||
public static func block(domain: String) -> Request<Empty> {
|
||||
return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: .parameters([
|
||||
return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: ParametersBody([
|
||||
"domain" => domain
|
||||
]))
|
||||
}
|
||||
|
||||
public static func unblock(domain: String) -> Request<Empty> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: .parameters([
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: ParametersBody([
|
||||
"domain" => domain
|
||||
]))
|
||||
}
|
||||
|
@ -185,7 +195,7 @@ public class Client {
|
|||
}
|
||||
|
||||
public static func createFilter(phrase: String, context: [Filter.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
|
||||
return Request<Filter>(method: .post, path: "/api/v1/filters", body: .parameters([
|
||||
return Request<Filter>(method: .post, path: "/api/v1/filters", body: ParametersBody([
|
||||
"phrase" => phrase,
|
||||
"irreversible" => irreversible,
|
||||
"whole_word" => wholeWord,
|
||||
|
@ -209,7 +219,7 @@ public class Client {
|
|||
}
|
||||
|
||||
public static func followRemote(acct: String) -> Request<Account> {
|
||||
return Request<Account>(method: .post, path: "/api/v1/follows", body: .parameters(["uri" => acct]))
|
||||
return Request<Account>(method: .post, path: "/api/v1/follows", body: ParametersBody(["uri" => acct]))
|
||||
}
|
||||
|
||||
// MARK: - Lists
|
||||
|
@ -222,12 +232,12 @@ public class Client {
|
|||
}
|
||||
|
||||
public static func createList(title: String) -> Request<List> {
|
||||
return Request<List>(method: .post, path: "/api/v1/lists", body: .parameters(["title" => title]))
|
||||
return Request<List>(method: .post, path: "/api/v1/lists", body: ParametersBody(["title" => title]))
|
||||
}
|
||||
|
||||
// MARK: - Media
|
||||
public static func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil) -> Request<Attachment> {
|
||||
return Request<Attachment>(method: .post, path: "/api/v1/media", body: .formData([
|
||||
return Request<Attachment>(method: .post, path: "/api/v1/media", body: FormDataBody([
|
||||
"description" => description,
|
||||
"focus" => focus
|
||||
], attachment))
|
||||
|
@ -259,7 +269,7 @@ public class Client {
|
|||
}
|
||||
|
||||
public static func report(account: Account, statuses: [Status], comment: String) -> Request<Report> {
|
||||
return Request<Report>(method: .post, path: "/api/v1/reports", body: .parameters([
|
||||
return Request<Report>(method: .post, path: "/api/v1/reports", body: ParametersBody([
|
||||
"account_id" => account.id,
|
||||
"comment" => comment
|
||||
] + "status_ids" => statuses.map { $0.id }))
|
||||
|
@ -287,7 +297,7 @@ public class Client {
|
|||
spoilerText: String? = nil,
|
||||
visibility: Status.Visibility? = nil,
|
||||
language: String? = nil) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses", body: .parameters([
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
|
||||
"status" => text,
|
||||
"content_type" => contentType.mimeType,
|
||||
"in_reply_to_id" => inReplyTo,
|
||||
|
@ -314,12 +324,32 @@ public class Client {
|
|||
}
|
||||
|
||||
extension Client {
|
||||
public enum Error: Swift.Error {
|
||||
case unknownError
|
||||
public enum Error: LocalizedError {
|
||||
case networkError(Swift.Error)
|
||||
case unexpectedStatus(Int)
|
||||
case invalidRequest
|
||||
case invalidResponse
|
||||
case invalidModel
|
||||
case mastodonError(String)
|
||||
|
||||
public var localizedDescription: String {
|
||||
switch self {
|
||||
case .networkError(let error):
|
||||
return "Network Error: \(error.localizedDescription)"
|
||||
// todo: support more status codes
|
||||
case .unexpectedStatus(413):
|
||||
return "HTTP 413: Payload Too Large"
|
||||
case .unexpectedStatus(let code):
|
||||
return "HTTP Code \(code)"
|
||||
case .invalidRequest:
|
||||
return "Invalid Request"
|
||||
case .invalidResponse:
|
||||
return "Invalid Response"
|
||||
case .invalidModel:
|
||||
return "Invalid Model"
|
||||
case .mastodonError(let error):
|
||||
return "Server Error: \(error)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -115,7 +115,7 @@ public final class Account: AccountProtocol, Decodable {
|
|||
}
|
||||
|
||||
public static func mute(_ account: Account, notifications: Bool? = nil) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/mute", body: .parameters([
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/mute", body: ParametersBody([
|
||||
"notifications" => notifications
|
||||
]))
|
||||
}
|
||||
|
|
|
@ -13,13 +13,14 @@ public class Attachment: Codable {
|
|||
public let kind: Kind
|
||||
public let url: URL
|
||||
public let remoteURL: URL?
|
||||
public let previewURL: URL
|
||||
public let previewURL: URL?
|
||||
public let textURL: URL?
|
||||
public let meta: Metadata?
|
||||
public let description: String?
|
||||
public let blurHash: String?
|
||||
|
||||
public static func update(_ attachment: Attachment, focus: (Float, Float)?, description: String?) -> Request<Attachment> {
|
||||
return Request<Attachment>(method: .put, path: "/api/v1/media/\(attachment.id)", body: .formData([
|
||||
return Request<Attachment>(method: .put, path: "/api/v1/media/\(attachment.id)", body: FormDataBody([
|
||||
"description" => (description ?? attachment.description),
|
||||
"focus" => focus
|
||||
], nil))
|
||||
|
@ -30,11 +31,12 @@ public class Attachment: Codable {
|
|||
self.id = try container.decode(String.self, forKey: .id)
|
||||
self.kind = try container.decode(Kind.self, forKey: .kind)
|
||||
self.url = try container.decode(URL.self, forKey: .url)
|
||||
self.previewURL = try container.decode(URL.self, forKey: .previewURL)
|
||||
self.remoteURL = try? container.decode(URL.self, forKey: .remoteURL)
|
||||
self.textURL = try? container.decode(URL.self, forKey: .textURL)
|
||||
self.meta = try? container.decode(Metadata.self, forKey: .meta)
|
||||
self.description = try? container.decode(String.self, forKey: .description)
|
||||
self.previewURL = try? container.decode(URL?.self, forKey: .previewURL)
|
||||
self.remoteURL = try? container.decode(URL?.self, forKey: .remoteURL)
|
||||
self.textURL = try? container.decode(URL?.self, forKey: .textURL)
|
||||
self.meta = try? container.decode(Metadata?.self, forKey: .meta)
|
||||
self.description = try? container.decode(String?.self, forKey: .description)
|
||||
self.blurHash = try? container.decode(String?.self, forKey: .blurHash)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
|
@ -46,6 +48,7 @@ public class Attachment: Codable {
|
|||
case textURL = "text_url"
|
||||
case meta
|
||||
case description
|
||||
case blurHash = "blurhash"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -60,7 +63,7 @@ extension Attachment {
|
|||
}
|
||||
|
||||
extension Attachment {
|
||||
public class Metadata: Codable {
|
||||
public struct Metadata: Codable {
|
||||
public let length: String?
|
||||
public let duration: Float?
|
||||
public let audioEncoding: String?
|
||||
|
@ -91,7 +94,7 @@ extension Attachment {
|
|||
}
|
||||
}
|
||||
|
||||
public class ImageMetadata: Codable {
|
||||
public struct ImageMetadata: Codable {
|
||||
public let width: Int?
|
||||
public let height: Int?
|
||||
public let size: String?
|
||||
|
|
|
@ -23,7 +23,7 @@ public class Filter: Decodable {
|
|||
}
|
||||
|
||||
public static func update(_ filter: Filter, phrase: String? = nil, context: [Context]? = nil, irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
|
||||
return Request<Filter>(method: .put, path: "/api/v1/filters/\(filter.id)", body: .parameters([
|
||||
return Request<Filter>(method: .put, path: "/api/v1/filters/\(filter.id)", body: ParametersBody([
|
||||
"phrase" => (phrase ?? filter.phrase),
|
||||
"irreversible" => (irreversible ?? filter.irreversible),
|
||||
"whole_word" => (wholeWord ?? filter.wholeWord),
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class List: Decodable {
|
||||
public class List: Decodable, Equatable, Hashable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
|
||||
|
@ -16,6 +16,14 @@ public class List: Decodable {
|
|||
return .list(id: id)
|
||||
}
|
||||
|
||||
public static func ==(lhs: List, rhs: List) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts")
|
||||
request.range = range
|
||||
|
@ -23,7 +31,7 @@ public class List: Decodable {
|
|||
}
|
||||
|
||||
public static func update(_ list: List, title: String) -> Request<List> {
|
||||
return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: .parameters(["title" => title]))
|
||||
return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: ParametersBody(["title" => title]))
|
||||
}
|
||||
|
||||
public static func delete(_ list: List) -> Request<Empty> {
|
||||
|
@ -31,13 +39,13 @@ public class List: Decodable {
|
|||
}
|
||||
|
||||
public static func add(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
|
||||
return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: .parameters(
|
||||
return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
|
||||
"account_ids" => accountIDs
|
||||
))
|
||||
}
|
||||
|
||||
public static func remove(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: .parameters(
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
|
||||
"account_ids" => accountIDs
|
||||
))
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ public class Notification: Decodable {
|
|||
}
|
||||
|
||||
public static func dismiss(id notificationID: String) -> Request<Empty> {
|
||||
return Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: .parameters([
|
||||
return Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: ParametersBody([
|
||||
"id" => notificationID
|
||||
]))
|
||||
}
|
||||
|
|
|
@ -8,56 +8,82 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
enum Body {
|
||||
case parameters([Parameter]?)
|
||||
case formData([Parameter]?, FormAttachment?)
|
||||
case empty
|
||||
protocol Body {
|
||||
var mimeType: String? { get }
|
||||
var data: Data? { get }
|
||||
}
|
||||
|
||||
extension Body {
|
||||
private static let boundary: String = "PachydermBoundary"
|
||||
struct EmptyBody: Body {
|
||||
var mimeType: String? { nil }
|
||||
var data: Data? { nil }
|
||||
}
|
||||
|
||||
struct ParametersBody: Body {
|
||||
let parameters: [Parameter]?
|
||||
|
||||
init(_ parmaeters: [Parameter]?) {
|
||||
self.parameters = parmaeters
|
||||
}
|
||||
|
||||
var mimeType: String? {
|
||||
if parameters == nil || parameters!.isEmpty {
|
||||
return nil
|
||||
}
|
||||
return "application/x-www-form-urlencoded; charset=utf-8"
|
||||
}
|
||||
|
||||
var data: Data? {
|
||||
switch self {
|
||||
case let .parameters(parameters):
|
||||
return parameters?.urlEncoded.data(using: .utf8)
|
||||
case let .formData(parameters, attachment):
|
||||
}
|
||||
}
|
||||
|
||||
struct FormDataBody: Body {
|
||||
private static let boundary = "PachydermBoundary"
|
||||
|
||||
let parameters: [Parameter]?
|
||||
let attachment: FormAttachment?
|
||||
|
||||
init(_ parameters: [Parameter]?, _ attachment: FormAttachment?) {
|
||||
self.parameters = parameters
|
||||
self.attachment = attachment
|
||||
}
|
||||
|
||||
var mimeType: String? {
|
||||
if parameters == nil && attachment == nil {
|
||||
return nil
|
||||
}
|
||||
return "multipart/form-data; boundary=\(FormDataBody.boundary)"
|
||||
}
|
||||
|
||||
var data: Data? {
|
||||
var data = Data()
|
||||
parameters?.forEach { param in
|
||||
guard let value = param.value else { return }
|
||||
data.append("--\(Body.boundary)\r\n")
|
||||
data.append("--\(FormDataBody.boundary)\r\n")
|
||||
data.append("Content-Disposition: form-data; name=\"\(param.name)\"\r\n\r\n")
|
||||
data.append("\(value)\r\n")
|
||||
}
|
||||
if let attachment = attachment {
|
||||
data.append("--\(Body.boundary)\r\n")
|
||||
data.append("--\(FormDataBody.boundary)\r\n")
|
||||
data.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(attachment.fileName)\"\r\n")
|
||||
data.append("Content-Type: \(attachment.mimeType)\r\n\r\n")
|
||||
data.append(attachment.data)
|
||||
data.append("\r\n")
|
||||
}
|
||||
|
||||
data.append("--\(Body.boundary)--\r\n")
|
||||
data.append("--\(FormDataBody.boundary)--\r\n")
|
||||
return data
|
||||
case .empty:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var mimeType: String? {
|
||||
switch self {
|
||||
case let .parameters(parameters):
|
||||
if parameters == nil {
|
||||
return nil
|
||||
}
|
||||
return "application/x-www-form-urlencoded; charset=utf-8"
|
||||
case let .formData(parameters, attachment):
|
||||
if parameters == nil && attachment == nil {
|
||||
return nil
|
||||
}
|
||||
return "multipart/form-data; boundary=\(Body.boundary)"
|
||||
case .empty:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct JsonBody<T: Encodable>: Body {
|
||||
let value: T
|
||||
|
||||
init(_ value: T) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
var mimeType: String? { "application/json" }
|
||||
|
||||
var data: Data? { try? Client.encoder.encode(value) }
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ public struct Request<ResultType: Decodable> {
|
|||
let body: Body
|
||||
var queryParameters: [Parameter]
|
||||
|
||||
init(method: Method, path: String, body: Body = .empty, queryParameters: [Parameter] = []) {
|
||||
init(method: Method, path: String, body: Body = EmptyBody(), queryParameters: [Parameter] = []) {
|
||||
self.method = method
|
||||
self.path = path
|
||||
self.body = body
|
||||
|
|
|
@ -10,5 +10,5 @@ import Foundation
|
|||
|
||||
public enum Response<Result: Decodable> {
|
||||
case success(Result, Pagination?)
|
||||
case failure(Error)
|
||||
case failure(Client.Error)
|
||||
}
|
||||
|
|
|
@ -22,16 +22,16 @@ public class InstanceSelector {
|
|||
let request = URLRequest(url: url)
|
||||
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
|
||||
if let error = error {
|
||||
completion(.failure(error))
|
||||
completion(.failure(.networkError(error)))
|
||||
return
|
||||
}
|
||||
guard let data = data,
|
||||
let response = response as? HTTPURLResponse else {
|
||||
completion(.failure(Client.Error.invalidResponse))
|
||||
completion(.failure(.invalidResponse))
|
||||
return
|
||||
}
|
||||
guard response.statusCode == 200 else {
|
||||
completion(.failure(Client.Error.unknownError))
|
||||
completion(.failure(.unexpectedStatus(response.statusCode)))
|
||||
return
|
||||
}
|
||||
guard let result = try? decoder.decode([Instance].self, from: data) else {
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
Subproject commit f445c9067d28346e828e615e2b43cb07b20bca35
|
|
@ -20,7 +20,7 @@
|
|||
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; };
|
||||
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; };
|
||||
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04ED00B021481ED800567C53 /* SteppedProgressView.swift */; };
|
||||
D60309B52419D4F100A465FF /* ComposeAttachmentsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60309B42419D4F100A465FF /* ComposeAttachmentsViewController.swift */; };
|
||||
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D60CFFDA24A290BA00D00083 /* SwiftSoup */; };
|
||||
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */; };
|
||||
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F232442372B005F8713 /* StatusMO.swift */; };
|
||||
D60E2F292442372B005F8713 /* AccountMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F252442372B005F8713 /* AccountMO.swift */; };
|
||||
|
@ -68,15 +68,20 @@
|
|||
D6109A11214607D500432DC2 /* Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6109A10214607D500432DC2 /* Timeline.swift */; };
|
||||
D611C2CF232DC61100C86A49 /* HashtagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */; };
|
||||
D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */; };
|
||||
D6163F2C21AA0AF1008DAC41 /* MyProfileTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6163F2B21AA0AF1008DAC41 /* MyProfileTableViewController.swift */; };
|
||||
D61AC1D3232E928600C54D2D /* InstanceSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D2232E928600C54D2D /* InstanceSelector.swift */; };
|
||||
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */; };
|
||||
D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */; };
|
||||
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */; };
|
||||
D620483223D2A6A3008A63EF /* CompositionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483123D2A6A3008A63EF /* CompositionState.swift */; };
|
||||
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
|
||||
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
|
||||
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
|
||||
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */; };
|
||||
D622757824EE133700B82A16 /* ComposeAssetPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757724EE133700B82A16 /* ComposeAssetPicker.swift */; };
|
||||
D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */; };
|
||||
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622759F24F1677200B82A16 /* ComposeHostingController.swift */; };
|
||||
D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A524F1C81800B82A16 /* ComposeReplyView.swift */; };
|
||||
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */; };
|
||||
D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A924F1E01C00B82A16 /* ComposeTextView.swift */; };
|
||||
D626493323BD751600612E6E /* ShowCameraCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D626493123BD751600612E6E /* ShowCameraCollectionViewCell.xib */; };
|
||||
D626493523BD94CE00612E6E /* CompositionAttachmentData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */; };
|
||||
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626493623C0FD0000612E6E /* AllPhotosTableViewCell.swift */; };
|
||||
|
@ -111,12 +116,15 @@
|
|||
D63569E023908A8D003DD353 /* StatusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60A4FFB238B726A008AC647 /* StatusState.swift */; };
|
||||
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */; };
|
||||
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */; };
|
||||
D63F9C66241C4CC3004C03CF /* AddAttachmentTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D63F9C65241C4CC3004C03CF /* AddAttachmentTableViewCell.xib */; };
|
||||
D63F9C68241C4F79004C03CF /* AddAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C67241C4F79004C03CF /* AddAttachmentTableViewCell.swift */; };
|
||||
D63F9C6B241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C69241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift */; };
|
||||
D63F9C6C241C50B9004C03CF /* ComposeAttachmentTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D63F9C6A241C50B9004C03CF /* ComposeAttachmentTableViewCell.xib */; };
|
||||
D63F9C6E241D2D85004C03CF /* CompositionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */; };
|
||||
D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */; };
|
||||
D640D76922BAF5E6004FBE69 /* DomainBlocks.plist in Resources */ = {isa = PBXBuildFile; fileRef = D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */; };
|
||||
D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */; };
|
||||
D6412B0524B0227D00F5412E /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0424B0227D00F5412E /* ProfileViewController.swift */; };
|
||||
D6412B0724B0237700F5412E /* ProfileStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0624B0237700F5412E /* ProfileStatusesViewController.swift */; };
|
||||
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0824B0291E00F5412E /* MyProfileViewController.swift */; };
|
||||
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */; };
|
||||
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */; };
|
||||
D641C773213CAA25004B4513 /* NotificationsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D641C772213CAA25004B4513 /* NotificationsTableViewController.swift */; };
|
||||
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */; };
|
||||
D6434EB3215B1856001A919A /* XCBRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6434EB2215B1856001A919A /* XCBRequest.swift */; };
|
||||
|
@ -137,21 +145,17 @@
|
|||
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */; };
|
||||
D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */; };
|
||||
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; };
|
||||
D65A37F321472F300087646E /* SwiftSoup.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */; };
|
||||
D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; };
|
||||
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; };
|
||||
D663625D2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D663625C2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib */; };
|
||||
D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663625E2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift */; };
|
||||
D663626221360B1900C9CBA2 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626121360B1900C9CBA2 /* Preferences.swift */; };
|
||||
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626321360D2300C9CBA2 /* AvatarStyle.swift */; };
|
||||
D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626B21361C6700C9CBA2 /* Account+Preferences.swift */; };
|
||||
D66362712136338600C9CBA2 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66362702136338600C9CBA2 /* ComposeViewController.swift */; };
|
||||
D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66362742137068A00C9CBA2 /* Visibility+Helpers.swift */; };
|
||||
D667383C23299340000A2373 /* InstanceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667383B23299340000A2373 /* InstanceType.swift */; };
|
||||
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */; };
|
||||
D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D667E5E02134937B0057A976 /* TimelineStatusTableViewCell.xib */; };
|
||||
D667E5E721349D4C0057A976 /* ProfileTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5E621349D4C0057A976 /* ProfileTableViewController.swift */; };
|
||||
D667E5E921349EE50057A976 /* ProfileHeaderTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D667E5E821349EE50057A976 /* ProfileHeaderTableViewCell.xib */; };
|
||||
D667E5EB21349EF80057A976 /* ProfileHeaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5EA21349EF80057A976 /* ProfileHeaderTableViewCell.swift */; };
|
||||
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F02134D5050057A976 /* UIViewController+Delegates.swift */; };
|
||||
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */; };
|
||||
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */; };
|
||||
|
@ -159,9 +163,14 @@
|
|||
D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7B2157E01900721E32 /* XCBManager.swift */; };
|
||||
D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */; };
|
||||
D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A812157E8FA00721E32 /* XCBSession.swift */; };
|
||||
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; };
|
||||
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; };
|
||||
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */; };
|
||||
D677284E24ECC01D00C732D3 /* Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284D24ECC01D00C732D3 /* Draft.swift */; };
|
||||
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */; };
|
||||
D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */; };
|
||||
D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D679C09E215850EF00DA27FE /* XCBActions.swift */; };
|
||||
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67B506C250B291200FAECFB /* BlurHashDecode.swift */; };
|
||||
D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */; };
|
||||
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */; };
|
||||
D67C57B221E28FAD00C3118B /* ComposeStatusReplyView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D67C57B121E28FAD00C3118B /* ComposeStatusReplyView.xib */; };
|
||||
|
@ -174,7 +183,11 @@
|
|||
D681E4D7246E32290053414F /* StatusActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D6246E32290053414F /* StatusActivityItemSource.swift */; };
|
||||
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */; };
|
||||
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */; };
|
||||
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */; };
|
||||
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; };
|
||||
D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525C24A3E8F00054355A /* SearchViewController.swift */; };
|
||||
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */; };
|
||||
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D690797224A4EF9700023A34 /* UIBezierPath+Helpers.swift */; };
|
||||
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */; };
|
||||
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5823FE24300061E07D /* InteractivePushTransition.swift */; };
|
||||
D6945C2F23AC47C3005C403C /* SavedDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6945C2E23AC47C3005C403C /* SavedDataManager.swift */; };
|
||||
|
@ -200,7 +213,6 @@
|
|||
D6A5BB2D23BBA9C4003BF21D /* MyProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */; };
|
||||
D6A5BB2F23BBAC97003BF21D /* DelegatingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */; };
|
||||
D6A5BB3123BBAD87003BF21D /* JSONResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB3023BBAD87003BF21D /* JSONResponse.swift */; };
|
||||
D6A5FAF1217B7E05003DB2D9 /* ComposeViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A5FAF0217B7E05003DB2D9 /* ComposeViewController.xib */; };
|
||||
D6AC956723C4347E008C9946 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AC956623C4347E008C9946 /* SceneDelegate.swift */; };
|
||||
D6ACE1AC240C3BAD004EA8E2 /* Ambassador.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D65F613023AE99E000F3CFD3 /* Ambassador.framework */; };
|
||||
D6ACE1AD240C3BAD004EA8E2 /* Embassy.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D65F612D23AE990C00F3CFD3 /* Embassy.framework */; };
|
||||
|
@ -217,6 +229,7 @@
|
|||
D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053A923BD2F1400A066FA /* AssetCollectionViewCell.swift */; };
|
||||
D6B053AC23BD2F1400A066FA /* AssetCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6B053AA23BD2F1400A066FA /* AssetCollectionViewCell.xib */; };
|
||||
D6B053AE23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053AD23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift */; };
|
||||
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */; };
|
||||
D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */; };
|
||||
D6BC874521961F73006163F1 /* Gifu.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6BC874421961F73006163F1 /* Gifu.framework */; };
|
||||
D6BC874621961F73006163F1 /* Gifu.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D6BC874421961F73006163F1 /* Gifu.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
|
@ -226,7 +239,6 @@
|
|||
D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */; };
|
||||
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; };
|
||||
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */; };
|
||||
D6BED170212663DA00F02DA0 /* SwiftSoup.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; };
|
||||
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; };
|
||||
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; };
|
||||
|
@ -234,8 +246,15 @@
|
|||
D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C7D27C22B6EBF800071952 /* AttachmentsContainerView.swift */; };
|
||||
D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C94D862139E62700CB5196 /* LargeImageViewController.swift */; };
|
||||
D6C94D892139E6EC00CB5196 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C94D882139E6EC00CB5196 /* AttachmentView.swift */; };
|
||||
D6C99FC724FACFAB005C74D3 /* ActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */; };
|
||||
D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */; };
|
||||
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; };
|
||||
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; };
|
||||
D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */; };
|
||||
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDDF24F41B8400FF50A5 /* ComposeContainerView.swift */; };
|
||||
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; };
|
||||
D6D4CC91250D2C3100FCCF8D /* UIAccessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC90250D2C3100FCCF8D /* UIAccessibility.swift */; };
|
||||
D6D4CC94250DB86A00FCCF8D /* ComposeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */; };
|
||||
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */; };
|
||||
D6D4DDD7212518A200E1C4BB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D6D4DDD6212518A200E1C4BB /* Assets.xcassets */; };
|
||||
D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */; };
|
||||
|
@ -250,6 +269,8 @@
|
|||
D6E6F26521604242006A8599 /* CharacterCounterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26421604242006A8599 /* CharacterCounterTests.swift */; };
|
||||
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; };
|
||||
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */; };
|
||||
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; };
|
||||
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; };
|
||||
D6F1F84D2193B56E00F5FE67 /* Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F1F84C2193B56E00F5FE67 /* Cache.swift */; };
|
||||
D6F2E965249E8BFD005846BB /* CrashReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* CrashReporterViewController.swift */; };
|
||||
D6F2E966249E8BFD005846BB /* CrashReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* CrashReporterViewController.xib */; };
|
||||
|
@ -303,7 +324,6 @@
|
|||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
D61099C12144B0CC00432DC2 /* Pachyderm.framework in Embed Frameworks */,
|
||||
D6BED170212663DA00F02DA0 /* SwiftSoup.framework in Embed Frameworks */,
|
||||
D6BC874621961F73006163F1 /* Gifu.framework in Embed Frameworks */,
|
||||
0461A3912163CBAE00C0A807 /* Cache.framework in Embed Frameworks */,
|
||||
);
|
||||
|
@ -325,7 +345,6 @@
|
|||
04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarViewController.swift; sourceTree = "<group>"; };
|
||||
04DACE8D212CC7CC009840C4 /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = "<group>"; };
|
||||
04ED00B021481ED800567C53 /* SteppedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteppedProgressView.swift; sourceTree = "<group>"; };
|
||||
D60309B42419D4F100A465FF /* ComposeAttachmentsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentsViewController.swift; sourceTree = "<group>"; };
|
||||
D60A4FFB238B726A008AC647 /* StatusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusState.swift; sourceTree = "<group>"; };
|
||||
D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseStatusTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D60E2F232442372B005F8713 /* StatusMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMO.swift; sourceTree = "<group>"; };
|
||||
|
@ -375,15 +394,20 @@
|
|||
D6109A10214607D500432DC2 /* Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timeline.swift; sourceTree = "<group>"; };
|
||||
D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = HashtagTableViewCell.xib; sourceTree = "<group>"; };
|
||||
D6163F2B21AA0AF1008DAC41 /* MyProfileTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileTableViewController.swift; sourceTree = "<group>"; };
|
||||
D61AC1D2232E928600C54D2D /* InstanceSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelector.swift; sourceTree = "<group>"; };
|
||||
D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelectorTableViewController.swift; sourceTree = "<group>"; };
|
||||
D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InstanceTableViewCell.xib; sourceTree = "<group>"; };
|
||||
D620483123D2A6A3008A63EF /* CompositionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionState.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>"; };
|
||||
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
|
||||
D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentsList.swift; sourceTree = "<group>"; };
|
||||
D622757724EE133700B82A16 /* ComposeAssetPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAssetPicker.swift; sourceTree = "<group>"; };
|
||||
D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentRow.swift; sourceTree = "<group>"; };
|
||||
D622759F24F1677200B82A16 /* ComposeHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeHostingController.swift; sourceTree = "<group>"; };
|
||||
D62275A524F1C81800B82A16 /* ComposeReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyView.swift; sourceTree = "<group>"; };
|
||||
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyContentView.swift; sourceTree = "<group>"; };
|
||||
D62275A924F1E01C00B82A16 /* ComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTextView.swift; sourceTree = "<group>"; };
|
||||
D626493123BD751600612E6E /* ShowCameraCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ShowCameraCollectionViewCell.xib; sourceTree = "<group>"; };
|
||||
D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachmentData.swift; sourceTree = "<group>"; };
|
||||
D626493623C0FD0000612E6E /* AllPhotosTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllPhotosTableViewCell.swift; sourceTree = "<group>"; };
|
||||
|
@ -417,12 +441,15 @@
|
|||
D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = "<group>"; };
|
||||
D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesNavigationController.swift; sourceTree = "<group>"; };
|
||||
D6370B9B24421FF30092A7FF /* Tusker.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Tusker.xcdatamodel; sourceTree = "<group>"; };
|
||||
D63F9C65241C4CC3004C03CF /* AddAttachmentTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AddAttachmentTableViewCell.xib; sourceTree = "<group>"; };
|
||||
D63F9C67241C4F79004C03CF /* AddAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAttachmentTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D63F9C69241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D63F9C6A241C50B9004C03CF /* ComposeAttachmentTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ComposeAttachmentTableViewCell.xib; sourceTree = "<group>"; };
|
||||
D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachment.swift; sourceTree = "<group>"; };
|
||||
D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectImageButton.swift; sourceTree = "<group>"; };
|
||||
D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = DomainBlocks.plist; sourceTree = "<group>"; };
|
||||
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarScrollableViewController.swift; sourceTree = "<group>"; };
|
||||
D6412B0424B0227D00F5412E /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
|
||||
D6412B0624B0237700F5412E /* ProfileStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusesViewController.swift; sourceTree = "<group>"; };
|
||||
D6412B0824B0291E00F5412E /* MyProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileViewController.swift; sourceTree = "<group>"; };
|
||||
D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ProfileHeaderView.xib; sourceTree = "<group>"; };
|
||||
D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = "<group>"; };
|
||||
D641C772213CAA25004B4513 /* NotificationsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsTableViewController.swift; sourceTree = "<group>"; };
|
||||
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTextAttachment.swift; sourceTree = "<group>"; };
|
||||
D6434EB2215B1856001A919A /* XCBRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBRequest.swift; sourceTree = "<group>"; };
|
||||
|
@ -448,19 +475,16 @@
|
|||
D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTests.swift; sourceTree = "<group>"; };
|
||||
D65F613523AFD65900F3CFD3 /* Ambassador.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Ambassador.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D65F613723AFD65D00F3CFD3 /* Embassy.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Embassy.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusStateResolver.swift; sourceTree = "<group>"; };
|
||||
D663625C2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConversationMainStatusTableViewCell.xib; sourceTree = "<group>"; };
|
||||
D663625E2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationMainStatusTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D663626121360B1900C9CBA2 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
|
||||
D663626321360D2300C9CBA2 /* AvatarStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStyle.swift; sourceTree = "<group>"; };
|
||||
D663626B21361C6700C9CBA2 /* Account+Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Preferences.swift"; sourceTree = "<group>"; };
|
||||
D66362702136338600C9CBA2 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = "<group>"; };
|
||||
D66362742137068A00C9CBA2 /* Visibility+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Visibility+Helpers.swift"; sourceTree = "<group>"; };
|
||||
D667383B23299340000A2373 /* InstanceType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceType.swift; sourceTree = "<group>"; };
|
||||
D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppShortcutItems.swift; sourceTree = "<group>"; };
|
||||
D667E5E02134937B0057A976 /* TimelineStatusTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TimelineStatusTableViewCell.xib; sourceTree = "<group>"; };
|
||||
D667E5E621349D4C0057A976 /* ProfileTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileTableViewController.swift; sourceTree = "<group>"; };
|
||||
D667E5E821349EE50057A976 /* ProfileHeaderTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ProfileHeaderTableViewCell.xib; sourceTree = "<group>"; };
|
||||
D667E5EA21349EF80057A976 /* ProfileHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D667E5F02134D5050057A976 /* UIViewController+Delegates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Delegates.swift"; sourceTree = "<group>"; };
|
||||
D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTableViewController.swift; sourceTree = "<group>"; };
|
||||
D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Equatable.swift"; sourceTree = "<group>"; };
|
||||
|
@ -468,9 +492,14 @@
|
|||
D6757A7B2157E01900721E32 /* XCBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBManager.swift; sourceTree = "<group>"; };
|
||||
D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBRequestSpec.swift; sourceTree = "<group>"; };
|
||||
D6757A812157E8FA00721E32 /* XCBSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBSession.swift; sourceTree = "<group>"; };
|
||||
D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
|
||||
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = "<group>"; };
|
||||
D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAvatarImageView.swift; sourceTree = "<group>"; };
|
||||
D677284D24ECC01D00C732D3 /* Draft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Draft.swift; sourceTree = "<group>"; };
|
||||
D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PKDrawing+Render.swift"; sourceTree = "<group>"; };
|
||||
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAccountAvatarView.swift; sourceTree = "<group>"; };
|
||||
D679C09E215850EF00DA27FE /* XCBActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBActions.swift; sourceTree = "<group>"; };
|
||||
D67B506C250B291200FAECFB /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = "<group>"; };
|
||||
D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeAccountDetailView.swift; sourceTree = "<group>"; };
|
||||
D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Uniques.swift"; sourceTree = "<group>"; };
|
||||
D67C57B121E28FAD00C3118B /* ComposeStatusReplyView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ComposeStatusReplyView.xib; sourceTree = "<group>"; };
|
||||
|
@ -483,7 +512,11 @@
|
|||
D681E4D6246E32290053414F /* StatusActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActivityItemSource.swift; sourceTree = "<group>"; };
|
||||
D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountActivityItemSource.swift; sourceTree = "<group>"; };
|
||||
D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeDrawingViewController.swift; sourceTree = "<group>"; };
|
||||
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedProgressView.swift; sourceTree = "<group>"; };
|
||||
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = "<group>"; };
|
||||
D68E525C24A3E8F00054355A /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = "<group>"; };
|
||||
D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.swift; sourceTree = "<group>"; };
|
||||
D690797224A4EF9700023A34 /* UIBezierPath+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBezierPath+Helpers.swift"; sourceTree = "<group>"; };
|
||||
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedNavigationViewController.swift; sourceTree = "<group>"; };
|
||||
D693DE5823FE24300061E07D /* InteractivePushTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractivePushTransition.swift; sourceTree = "<group>"; };
|
||||
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedDataManager.swift; sourceTree = "<group>"; };
|
||||
|
@ -508,7 +541,6 @@
|
|||
D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileTests.swift; sourceTree = "<group>"; };
|
||||
D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelegatingResponse.swift; sourceTree = "<group>"; };
|
||||
D6A5BB3023BBAD87003BF21D /* JSONResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONResponse.swift; sourceTree = "<group>"; };
|
||||
D6A5FAF0217B7E05003DB2D9 /* ComposeViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ComposeViewController.xib; sourceTree = "<group>"; };
|
||||
D6AC956623C4347E008C9946 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
D6AEBB3D2321638100E5038B /* UIActivity+Types.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIActivity+Types.swift"; sourceTree = "<group>"; };
|
||||
D6AEBB402321642700E5038B /* SendMesasgeActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMesasgeActivity.swift; sourceTree = "<group>"; };
|
||||
|
@ -522,6 +554,7 @@
|
|||
D6B053A923BD2F1400A066FA /* AssetCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D6B053AA23BD2F1400A066FA /* AssetCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AssetCollectionViewCell.xib; sourceTree = "<group>"; };
|
||||
D6B053AD23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerSheetContainerViewController.swift; sourceTree = "<group>"; };
|
||||
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayNameLabel.swift; sourceTree = "<group>"; };
|
||||
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Visibility.swift"; sourceTree = "<group>"; };
|
||||
D6BC874421961F73006163F1 /* Gifu.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Gifu.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedTableViewController.swift; sourceTree = "<group>"; };
|
||||
|
@ -530,7 +563,6 @@
|
|||
D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsMode.swift; sourceTree = "<group>"; };
|
||||
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = "<group>"; };
|
||||
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = "<group>"; };
|
||||
D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SwiftSoup.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = "<group>"; };
|
||||
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; };
|
||||
|
@ -538,8 +570,15 @@
|
|||
D6C7D27C22B6EBF800071952 /* AttachmentsContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentsContainerView.swift; sourceTree = "<group>"; };
|
||||
D6C94D862139E62700CB5196 /* LargeImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageViewController.swift; sourceTree = "<group>"; };
|
||||
D6C94D882139E6EC00CB5196 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = "<group>"; };
|
||||
D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorView.swift; sourceTree = "<group>"; };
|
||||
D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainComposeTextView.swift; sourceTree = "<group>"; };
|
||||
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; };
|
||||
D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; };
|
||||
D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = "<group>"; };
|
||||
D6D3FDDF24F41B8400FF50A5 /* ComposeContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeContainerView.swift; sourceTree = "<group>"; };
|
||||
D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = "<group>"; };
|
||||
D6D4CC90250D2C3100FCCF8D /* UIAccessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAccessibility.swift; sourceTree = "<group>"; };
|
||||
D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTests.swift; sourceTree = "<group>"; };
|
||||
D6D4DDCC212518A000E1C4BB /* Tusker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tusker.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
D6D4DDD6212518A200E1C4BB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
|
@ -556,16 +595,18 @@
|
|||
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadScrollGestureRecognizer.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>"; };
|
||||
D6E4885C24A2890C0011C13E /* Tusker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Tusker.entitlements; sourceTree = "<group>"; };
|
||||
D6E6F26221603F8B006A8599 /* CharacterCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCounter.swift; sourceTree = "<group>"; };
|
||||
D6E6F26421604242006A8599 /* CharacterCounterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCounterTests.swift; sourceTree = "<group>"; };
|
||||
D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = "<group>"; };
|
||||
D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISceneSession+MastodonController.swift"; sourceTree = "<group>"; };
|
||||
D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSplitViewController.swift; sourceTree = "<group>"; };
|
||||
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = "<group>"; };
|
||||
D6F1F84C2193B56E00F5FE67 /* Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cache.swift; sourceTree = "<group>"; };
|
||||
D6F2E963249E8BFD005846BB /* CrashReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashReporterViewController.swift; sourceTree = "<group>"; };
|
||||
D6F2E964249E8BFD005846BB /* CrashReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CrashReporterViewController.xib; sourceTree = "<group>"; };
|
||||
D6F953EB212519E700CF0F2B /* TimelineTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewController.swift; sourceTree = "<group>"; };
|
||||
D6F953EF21251A2900CF0F2B /* MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonController.swift; sourceTree = "<group>"; };
|
||||
D6F98BD523AE951F008A4DAC /* Swifter.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Swifter.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
|
@ -590,10 +631,10 @@
|
|||
files = (
|
||||
D61099C02144B0CC00432DC2 /* Pachyderm.framework in Frameworks */,
|
||||
D6B0539F23BD2BA300A066FA /* SheetController in Frameworks */,
|
||||
D65A37F321472F300087646E /* SwiftSoup.framework in Frameworks */,
|
||||
D69CCBBF249E6EFD000AF167 /* CrashReporter in Frameworks */,
|
||||
D6BC874521961F73006163F1 /* Gifu.framework in Frameworks */,
|
||||
0461A3902163CBAE00C0A807 /* Cache.framework in Frameworks */,
|
||||
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -741,24 +782,14 @@
|
|||
path = "Draft Cell";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D61959D1241E844900A37B8E /* Attachment Cells */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D63F9C67241C4F79004C03CF /* AddAttachmentTableViewCell.swift */,
|
||||
D63F9C65241C4CC3004C03CF /* AddAttachmentTableViewCell.xib */,
|
||||
D63F9C69241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift */,
|
||||
D63F9C6A241C50B9004C03CF /* ComposeAttachmentTableViewCell.xib */,
|
||||
);
|
||||
path = "Attachment Cells";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D61959D2241E846D00A37B8E /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6285B5221EA708700FE4B39 /* StatusFormat.swift */,
|
||||
D620483123D2A6A3008A63EF /* CompositionState.swift */,
|
||||
D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */,
|
||||
D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */,
|
||||
D677284D24ECC01D00C732D3 /* Draft.swift */,
|
||||
D627FF75217E923E00CC0648 /* DraftsManager.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
|
@ -899,7 +930,10 @@
|
|||
D641C782213DD7F0004B4513 /* Main */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */,
|
||||
04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */,
|
||||
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */,
|
||||
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */,
|
||||
);
|
||||
path = Main;
|
||||
sourceTree = "<group>";
|
||||
|
@ -916,8 +950,9 @@
|
|||
D641C784213DD819004B4513 /* Profile */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D667E5E621349D4C0057A976 /* ProfileTableViewController.swift */,
|
||||
D6163F2B21AA0AF1008DAC41 /* MyProfileTableViewController.swift */,
|
||||
D6412B0424B0227D00F5412E /* ProfileViewController.swift */,
|
||||
D6412B0624B0237700F5412E /* ProfileStatusesViewController.swift */,
|
||||
D6412B0824B0291E00F5412E /* MyProfileViewController.swift */,
|
||||
);
|
||||
path = Profile;
|
||||
sourceTree = "<group>";
|
||||
|
@ -942,10 +977,20 @@
|
|||
D641C787213DD862004B4513 /* Compose */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6A5FAF0217B7E05003DB2D9 /* ComposeViewController.xib */,
|
||||
D66362702136338600C9CBA2 /* ComposeViewController.swift */,
|
||||
D60309B42419D4F100A465FF /* ComposeAttachmentsViewController.swift */,
|
||||
D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */,
|
||||
D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */,
|
||||
D622759F24F1677200B82A16 /* ComposeHostingController.swift */,
|
||||
D6D3FDDF24F41B8400FF50A5 /* ComposeContainerView.swift */,
|
||||
D677284724ECBCB100C732D3 /* ComposeView.swift */,
|
||||
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */,
|
||||
D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */,
|
||||
D62275A924F1E01C00B82A16 /* ComposeTextView.swift */,
|
||||
D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */,
|
||||
D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */,
|
||||
D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */,
|
||||
D622757724EE133700B82A16 /* ComposeAssetPicker.swift */,
|
||||
D62275A524F1C81800B82A16 /* ComposeReplyView.swift */,
|
||||
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */,
|
||||
);
|
||||
path = Compose;
|
||||
sourceTree = "<group>";
|
||||
|
@ -994,8 +1039,8 @@
|
|||
D641C78B213DD92F004B4513 /* Profile Header */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D667E5E821349EE50057A976 /* ProfileHeaderTableViewCell.xib */,
|
||||
D667E5EA21349EF80057A976 /* ProfileHeaderTableViewCell.swift */,
|
||||
D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */,
|
||||
D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */,
|
||||
);
|
||||
path = "Profile Header";
|
||||
sourceTree = "<group>";
|
||||
|
@ -1031,7 +1076,6 @@
|
|||
D65F613523AFD65900F3CFD3 /* Ambassador.framework */,
|
||||
D65F613023AE99E000F3CFD3 /* Ambassador.framework */,
|
||||
D65F612D23AE990C00F3CFD3 /* Embassy.framework */,
|
||||
D6F98BD523AE951F008A4DAC /* Swifter.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1064,6 +1108,10 @@
|
|||
D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */,
|
||||
D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */,
|
||||
D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */,
|
||||
D690797224A4EF9700023A34 /* UIBezierPath+Helpers.swift */,
|
||||
D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */,
|
||||
D6D4CC90250D2C3100FCCF8D /* UIAccessibility.swift */,
|
||||
D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1081,6 +1129,14 @@
|
|||
path = XCallbackURL;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D67B506B250B28FF00FAECFB /* Vendor */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D67B506C250B291200FAECFB /* BlurHashDecode.swift */,
|
||||
);
|
||||
path = Vendor;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D67C57A721E2649B00C3118B /* Account Detail */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -1194,6 +1250,7 @@
|
|||
D6BC9DD8232D8BCA002CA326 /* Search */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D68E525C24A3E8F00054355A /* SearchViewController.swift */,
|
||||
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */,
|
||||
);
|
||||
path = Search;
|
||||
|
@ -1211,10 +1268,13 @@
|
|||
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
|
||||
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
|
||||
D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */,
|
||||
D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */,
|
||||
D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */,
|
||||
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */,
|
||||
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
|
||||
D67C57A721E2649B00C3118B /* Account Detail */,
|
||||
D67C57B021E28F9400C3118B /* Compose Status Reply */,
|
||||
D626494023C122C800612E6E /* Asset Picker */,
|
||||
D61959D1241E844900A37B8E /* Attachment Cells */,
|
||||
D61959D0241E842400A37B8E /* Draft Cell */,
|
||||
D641C78A213DD926004B4513 /* Status */,
|
||||
D6C7D27B22B6EBE200071952 /* Attachments */,
|
||||
|
@ -1240,6 +1300,7 @@
|
|||
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */,
|
||||
D693DE5823FE24300061E07D /* InteractivePushTransition.swift */,
|
||||
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
|
||||
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */,
|
||||
);
|
||||
path = Utilities;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1259,7 +1320,6 @@
|
|||
children = (
|
||||
D6BC874421961F73006163F1 /* Gifu.framework */,
|
||||
0461A38F2163CBAE00C0A807 /* Cache.framework */,
|
||||
D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */,
|
||||
D61099AC2144B0CC00432DC2 /* Pachyderm */,
|
||||
D61099B92144B0CC00432DC2 /* PachydermTests */,
|
||||
D6D4DDCE212518A000E1C4BB /* Tusker */,
|
||||
|
@ -1285,16 +1345,17 @@
|
|||
D6D4DDCE212518A000E1C4BB /* Tusker */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6E4885C24A2890C0011C13E /* Tusker.entitlements */,
|
||||
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
|
||||
D6AC956623C4347E008C9946 /* SceneDelegate.swift */,
|
||||
D64D0AAC2128D88B005A6F37 /* LocalData.swift */,
|
||||
D627FF75217E923E00CC0648 /* DraftsManager.swift */,
|
||||
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
|
||||
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */,
|
||||
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */,
|
||||
D64D8CA82463B494006B0BAA /* CachedDictionary.swift */,
|
||||
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
|
||||
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
|
||||
D67B506B250B28FF00FAECFB /* Vendor */,
|
||||
D6F1F84E2193B9BE00F5FE67 /* Caching */,
|
||||
D6757A7A2157E00100721E32 /* XCallbackURL */,
|
||||
D62D241E217AA46B005076CC /* Shortcuts */,
|
||||
|
@ -1331,6 +1392,7 @@
|
|||
D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */,
|
||||
D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */,
|
||||
D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */,
|
||||
D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */,
|
||||
D6A5BB2623BAC88E003BF21D /* Preferences */,
|
||||
D6D4DDF1212518A200E1C4BB /* Info.plist */,
|
||||
);
|
||||
|
@ -1433,6 +1495,7 @@
|
|||
packageProductDependencies = (
|
||||
D6B0539E23BD2BA300A066FA /* SheetController */,
|
||||
D69CCBBE249E6EFD000AF167 /* CrashReporter */,
|
||||
D60CFFDA24A290BA00D00083 /* SwiftSoup */,
|
||||
);
|
||||
productName = Tusker;
|
||||
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
|
||||
|
@ -1482,7 +1545,7 @@
|
|||
D6D4DDC4212518A000E1C4BB /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 1000;
|
||||
LastSwiftUpdateCheck = 1200;
|
||||
LastUpgradeCheck = 1020;
|
||||
ORGANIZATIONNAME = Shadowfacts;
|
||||
TargetAttributes = {
|
||||
|
@ -1497,7 +1560,7 @@
|
|||
};
|
||||
D6D4DDCB212518A000E1C4BB = {
|
||||
CreatedOnToolsVersion = 10.0;
|
||||
LastSwiftMigration = 1020;
|
||||
LastSwiftMigration = 1200;
|
||||
};
|
||||
D6D4DDDF212518A200E1C4BB = {
|
||||
CreatedOnToolsVersion = 10.0;
|
||||
|
@ -1529,6 +1592,7 @@
|
|||
packageReferences = (
|
||||
D6B0539D23BD2BA300A066FA /* XCRemoteSwiftPackageReference "SheetController" */,
|
||||
D69CCBBD249E6EFD000AF167 /* XCRemoteSwiftPackageReference "plcrashreporter" */,
|
||||
D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
|
||||
);
|
||||
productRefGroup = D6D4DDCD212518A000E1C4BB /* Products */;
|
||||
projectDirPath = "";
|
||||
|
@ -1568,7 +1632,7 @@
|
|||
D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */,
|
||||
D627FF7D217E958900CC0648 /* DraftTableViewCell.xib in Resources */,
|
||||
D6A3BC7D232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib in Resources */,
|
||||
D667E5E921349EE50057A976 /* ProfileHeaderTableViewCell.xib in Resources */,
|
||||
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */,
|
||||
D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */,
|
||||
D6B053AC23BD2F1400A066FA /* AssetCollectionViewCell.xib in Resources */,
|
||||
D6D4DDD7212518A200E1C4BB /* Assets.xcassets in Resources */,
|
||||
|
@ -1582,11 +1646,8 @@
|
|||
D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */,
|
||||
D64BC19023C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib in Resources */,
|
||||
D67C57B221E28FAD00C3118B /* ComposeStatusReplyView.xib in Resources */,
|
||||
D63F9C6C241C50B9004C03CF /* ComposeAttachmentTableViewCell.xib in Resources */,
|
||||
D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */,
|
||||
D63F9C66241C4CC3004C03CF /* AddAttachmentTableViewCell.xib in Resources */,
|
||||
D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */,
|
||||
D6A5FAF1217B7E05003DB2D9 /* ComposeViewController.xib in Resources */,
|
||||
D6F2E966249E8BFD005846BB /* CrashReporterViewController.xib in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -1701,10 +1762,13 @@
|
|||
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */,
|
||||
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
|
||||
D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */,
|
||||
D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */,
|
||||
D60E2F292442372B005F8713 /* AccountMO.swift in Sources */,
|
||||
D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */,
|
||||
D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */,
|
||||
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */,
|
||||
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */,
|
||||
D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */,
|
||||
D6AEBB412321642700E5038B /* SendMesasgeActivity.swift in Sources */,
|
||||
D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */,
|
||||
D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */,
|
||||
|
@ -1714,18 +1778,21 @@
|
|||
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */,
|
||||
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */,
|
||||
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
|
||||
D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */,
|
||||
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */,
|
||||
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */,
|
||||
D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */,
|
||||
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */,
|
||||
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */,
|
||||
D6C99FC724FACFAB005C74D3 /* ActivityIndicatorView.swift in Sources */,
|
||||
D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */,
|
||||
0411610022B442870030A9B7 /* LoadingLargeImageViewController.swift in Sources */,
|
||||
D6412B0724B0237700F5412E /* ProfileStatusesViewController.swift in Sources */,
|
||||
D611C2CF232DC61100C86A49 /* HashtagTableViewCell.swift in Sources */,
|
||||
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */,
|
||||
D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */,
|
||||
D627943723A552C200D38C68 /* BookmarkStatusActivity.swift in Sources */,
|
||||
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
|
||||
D66362712136338600C9CBA2 /* ComposeViewController.swift in Sources */,
|
||||
D6AC956723C4347E008C9946 /* SceneDelegate.swift in Sources */,
|
||||
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */,
|
||||
D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */,
|
||||
|
@ -1735,12 +1802,13 @@
|
|||
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */,
|
||||
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */,
|
||||
D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */,
|
||||
D60309B52419D4F100A465FF /* ComposeAttachmentsViewController.swift in Sources */,
|
||||
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
|
||||
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
|
||||
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */,
|
||||
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
|
||||
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */,
|
||||
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */,
|
||||
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */,
|
||||
D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */,
|
||||
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */,
|
||||
D6A3BC852321F6C100FD64D5 /* AccountListTableViewController.swift in Sources */,
|
||||
|
@ -1749,20 +1817,25 @@
|
|||
D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */,
|
||||
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */,
|
||||
D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */,
|
||||
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
|
||||
D67C57B421E2910700C3118B /* ComposeStatusReplyView.swift in Sources */,
|
||||
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
|
||||
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
|
||||
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,
|
||||
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
|
||||
D627FF7B217E951500CC0648 /* DraftsTableViewController.swift in Sources */,
|
||||
D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */,
|
||||
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */,
|
||||
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
|
||||
D6A3BC8F2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift in Sources */,
|
||||
D6AEBB4823216B1D00E5038B /* AccountActivity.swift in Sources */,
|
||||
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */,
|
||||
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */,
|
||||
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
|
||||
D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */,
|
||||
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */,
|
||||
D63F9C6B241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift in Sources */,
|
||||
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */,
|
||||
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */,
|
||||
D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */,
|
||||
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */,
|
||||
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */,
|
||||
|
@ -1775,6 +1848,8 @@
|
|||
D6AEBB4523216AF800E5038B /* FollowAccountActivity.swift in Sources */,
|
||||
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */,
|
||||
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */,
|
||||
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */,
|
||||
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */,
|
||||
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
|
||||
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */,
|
||||
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */,
|
||||
|
@ -1785,8 +1860,10 @@
|
|||
D6B053AE23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift in Sources */,
|
||||
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */,
|
||||
D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */,
|
||||
D6D4CC91250D2C3100FCCF8D /* UIAccessibility.swift in Sources */,
|
||||
D627943E23A564D400D38C68 /* ExploreViewController.swift in Sources */,
|
||||
D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */,
|
||||
D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */,
|
||||
D6434EB3215B1856001A919A /* XCBRequest.swift in Sources */,
|
||||
D681E4D3246E2AFF0053414F /* MuteConversationActivity.swift in Sources */,
|
||||
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */,
|
||||
|
@ -1795,16 +1872,19 @@
|
|||
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
|
||||
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
|
||||
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
|
||||
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
|
||||
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
|
||||
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
|
||||
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
|
||||
D627943523A5525100D38C68 /* StatusActivity.swift in Sources */,
|
||||
D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */,
|
||||
D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */,
|
||||
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */,
|
||||
D622757824EE133700B82A16 /* ComposeAssetPicker.swift in Sources */,
|
||||
D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */,
|
||||
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */,
|
||||
D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */,
|
||||
D63F9C68241C4F79004C03CF /* AddAttachmentTableViewCell.swift in Sources */,
|
||||
D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */,
|
||||
D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */,
|
||||
D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */,
|
||||
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
|
||||
|
@ -1823,34 +1903,39 @@
|
|||
D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */,
|
||||
D6A3BC7C232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift in Sources */,
|
||||
D6F953EC212519E700CF0F2B /* TimelineTableViewController.swift in Sources */,
|
||||
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
|
||||
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
|
||||
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
|
||||
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
|
||||
D620483223D2A6A3008A63EF /* CompositionState.swift in Sources */,
|
||||
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */,
|
||||
D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */,
|
||||
D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */,
|
||||
D667E5EB21349EF80057A976 /* ProfileHeaderTableViewCell.swift in Sources */,
|
||||
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */,
|
||||
04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */,
|
||||
D641C773213CAA25004B4513 /* NotificationsTableViewController.swift in Sources */,
|
||||
D6412B0524B0227D00F5412E /* ProfileViewController.swift in Sources */,
|
||||
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */,
|
||||
D64D8CA92463B494006B0BAA /* CachedDictionary.swift in Sources */,
|
||||
D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */,
|
||||
D6F1F84D2193B56E00F5FE67 /* Cache.swift in Sources */,
|
||||
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,
|
||||
0427037C22B316B9000D31B6 /* SilentActionPrefs.swift in Sources */,
|
||||
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */,
|
||||
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */,
|
||||
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */,
|
||||
D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */,
|
||||
D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */,
|
||||
D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */,
|
||||
D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */,
|
||||
D677284E24ECC01D00C732D3 /* Draft.swift in Sources */,
|
||||
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
|
||||
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,
|
||||
D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */,
|
||||
D667E5E721349D4C0057A976 /* ProfileTableViewController.swift in Sources */,
|
||||
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
|
||||
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
|
||||
D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */,
|
||||
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
|
||||
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */,
|
||||
D6163F2C21AA0AF1008DAC41 /* MyProfileTableViewController.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -1872,6 +1957,7 @@
|
|||
D6A5BB2F23BBAC97003BF21D /* DelegatingResponse.swift in Sources */,
|
||||
D6A5BB2D23BBA9C4003BF21D /* MyProfileTests.swift in Sources */,
|
||||
D6A5BB2B23BAEF61003BF21D /* APIMocks.swift in Sources */,
|
||||
D6D4CC94250DB86A00FCCF8D /* ComposeTests.swift in Sources */,
|
||||
D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -2069,7 +2155,7 @@
|
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
|
@ -2125,7 +2211,7 @@
|
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
|
@ -2140,23 +2226,27 @@
|
|||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2020.1;
|
||||
OTHER_LDFLAGS = "";
|
||||
"OTHER_SWIFT_FLAGS[sdk=iphone*14*]" = "$(inherited) -D SDK_IOS_14";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = "1,2,6";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
|
@ -2165,22 +2255,26 @@
|
|||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2020.1;
|
||||
"OTHER_SWIFT_FLAGS[sdk=iphone*14*]" = "$(inherited) -D SDK_IOS_14";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = "1,2,6";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
|
@ -2326,6 +2420,14 @@
|
|||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/scinfu/SwiftSoup.git";
|
||||
requirement = {
|
||||
kind = upToNextMinorVersion;
|
||||
minimumVersion = 2.3.2;
|
||||
};
|
||||
};
|
||||
D69CCBBD249E6EFD000AF167 /* XCRemoteSwiftPackageReference "plcrashreporter" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/microsoft/plcrashreporter";
|
||||
|
@ -2345,6 +2447,11 @@
|
|||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
D60CFFDA24A290BA00D00083 /* SwiftSoup */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
|
||||
productName = SwiftSoup;
|
||||
};
|
||||
D69CCBBE249E6EFD000AF167 /* CrashReporter */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = D69CCBBD249E6EFD000AF167 /* XCRemoteSwiftPackageReference "plcrashreporter" */;
|
||||
|
|
|
@ -27,15 +27,6 @@
|
|||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D6D4DDCB212518A000E1C4BB"
|
||||
BuildableName = "Tusker.app"
|
||||
BlueprintName = "Tusker"
|
||||
ReferencedContainer = "container:Tusker.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
|
|
|
@ -10,9 +10,6 @@
|
|||
<FileRef
|
||||
location = "group:Cache/Cache.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:SwiftSoup/SwiftSoup.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Gifu/Gifu.xcodeproj">
|
||||
</FileRef>
|
||||
|
|
|
@ -6,8 +6,26 @@
|
|||
"repositoryURL": "https://github.com/microsoft/plcrashreporter",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "4637a7854de2cc5c354d46fb931d74bdbc2c043e",
|
||||
"version": "1.7.0"
|
||||
"revision": "6b7ca9a2faad6ea990ff60b0a3ee4fdf3db59150",
|
||||
"version": "1.7.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SheetController",
|
||||
"repositoryURL": "https://git.shadowfacts.net/shadowfacts/SheetController.git",
|
||||
"state": {
|
||||
"branch": "master",
|
||||
"revision": "aa0f5192eaf19d01c89dbfa9ec5878a700376f23",
|
||||
"version": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SwiftSoup",
|
||||
"repositoryURL": "https://github.com/scinfu/SwiftSoup.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "774dc9c7213085db8aa59595e27c1cd22e428904",
|
||||
"version": "2.3.2"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
@ -28,7 +28,9 @@ class SendMessageActivity: AccountActivity {
|
|||
override var activityViewController: UIViewController? {
|
||||
guard let account = account else { return nil }
|
||||
|
||||
return UINavigationController(rootViewController: ComposeViewController(mentioningAcct: account.acct, mastodonController: mastodonController))
|
||||
let draft = mastodonController.createDraft(mentioningAcct: account.acct)
|
||||
let compose = ComposeHostingController(draft: draft, mastodonController: mastodonController)
|
||||
return UINavigationController(rootViewController: compose)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -36,10 +36,10 @@ class OpenInSafariActivity: UIActivity {
|
|||
activityDidFinish(true)
|
||||
}
|
||||
|
||||
static func completionHandler(viewController: UIViewController, url: URL) -> UIActivityViewController.CompletionWithItemsHandler {
|
||||
static func completionHandler(navigator: TuskerNavigationDelegate, url: URL) -> UIActivityViewController.CompletionWithItemsHandler {
|
||||
return { (activityType, _, _, _) in
|
||||
if activityType == .openInSafari {
|
||||
viewController.present(SFSafariViewController(url: url), animated: true)
|
||||
navigator.show(SFSafariViewController(url: url))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,4 +47,15 @@ enum Cache<T> {
|
|||
try hybrid.setObject(object, forKey: key, expiry: expiry)
|
||||
}
|
||||
}
|
||||
|
||||
func removeAll() throws {
|
||||
switch self {
|
||||
case let .memory(memory):
|
||||
memory.removeAll()
|
||||
case let .disk(disk):
|
||||
try disk.removeAll()
|
||||
case let .hybrid(hybrid):
|
||||
try hybrid.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ class ImageCache {
|
|||
static let attachments = ImageCache(name: "Attachments", memoryExpiry: .seconds(60 * 2))
|
||||
static let emojis = ImageCache(name: "Emojis", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60))
|
||||
|
||||
let cache: Cache<Data>
|
||||
private let cache: Cache<Data>
|
||||
|
||||
private var groups = [URL: RequestGroup]()
|
||||
|
||||
|
@ -68,6 +68,10 @@ class ImageCache {
|
|||
groups[url]?.cancelWithoutCallback()
|
||||
}
|
||||
|
||||
func reset() throws {
|
||||
try cache.removeAll()
|
||||
}
|
||||
|
||||
private class RequestGroup {
|
||||
let url: URL
|
||||
private let onFinished: (Data?) -> Void
|
||||
|
|
|
@ -45,6 +45,10 @@ class MastodonController {
|
|||
var account: Account!
|
||||
var instance: Instance!
|
||||
|
||||
var loggedIn: Bool {
|
||||
accountInfo != nil
|
||||
}
|
||||
|
||||
init(instanceURL: URL, transient: Bool = false) {
|
||||
self.instanceURL = instanceURL
|
||||
self.accountInfo = nil
|
||||
|
@ -116,3 +120,6 @@ class MastodonController {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
// ObservableObject so that SwiftUI views can receive it through @EnvironmentObject
|
||||
extension MastodonController: ObservableObject {}
|
||||
|
|
|
@ -124,9 +124,19 @@ extension StatusMO {
|
|||
self.uri = status.uri
|
||||
self.url = status.url
|
||||
self.visibility = status.visibility
|
||||
self.account = container.account(for: status.account.id, in: context) ?? AccountMO(apiAccount: status.account, container: container, context: context)
|
||||
if let existing = container.account(for: status.account.id, in: context) {
|
||||
existing.updateFrom(apiAccount: status.account, container: container)
|
||||
self.account = existing
|
||||
} else {
|
||||
self.account = AccountMO(apiAccount: status.account, container: container, context: context)
|
||||
}
|
||||
if let reblog = status.reblog {
|
||||
self.reblog = container.status(for: reblog.id, in: context) ?? StatusMO(apiStatus: reblog, container: container, context: context)
|
||||
if let existing = container.status(for: reblog.id, in: context) {
|
||||
existing.updateFrom(apiStatus: reblog, container: container)
|
||||
self.reblog = existing
|
||||
} else {
|
||||
self.reblog = StatusMO(apiStatus: reblog, container: container, context: context)
|
||||
}
|
||||
} else {
|
||||
self.reblog = nil
|
||||
}
|
||||
|
|
|
@ -1,87 +0,0 @@
|
|||
//
|
||||
// DraftsManager.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 10/22/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class DraftsManager: Codable {
|
||||
|
||||
private(set) static var shared: DraftsManager = load()
|
||||
|
||||
private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
private static var archiveURL = DraftsManager.documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
|
||||
|
||||
static func save() {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let encoder = PropertyListEncoder()
|
||||
let data = try? encoder.encode(shared)
|
||||
try? data?.write(to: archiveURL, options: .noFileProtection)
|
||||
}
|
||||
}
|
||||
|
||||
static func load() -> DraftsManager {
|
||||
let decoder = PropertyListDecoder()
|
||||
if let data = try? Data(contentsOf: archiveURL),
|
||||
let draftsManager = try? decoder.decode(DraftsManager.self, from: data) {
|
||||
return draftsManager
|
||||
}
|
||||
return DraftsManager()
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
var drafts: [Draft] = []
|
||||
var sorted: [Draft] {
|
||||
return drafts.sorted(by: { $0.lastModified > $1.lastModified })
|
||||
}
|
||||
|
||||
func create(accountID: String, text: String, contentWarning: String?, inReplyToID: String?, attachments: [CompositionAttachment]) -> Draft {
|
||||
let draft = Draft(accountID: accountID, text: text, contentWarning: contentWarning, inReplyToID: inReplyToID, attachments: attachments)
|
||||
drafts.append(draft)
|
||||
return draft
|
||||
}
|
||||
|
||||
func remove(_ draft: Draft) {
|
||||
let index = drafts.firstIndex(of: draft)!
|
||||
drafts.remove(at: index)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension DraftsManager {
|
||||
class Draft: Codable, Equatable {
|
||||
let id: UUID
|
||||
private(set) var accountID: String
|
||||
private(set) var text: String
|
||||
private(set) var contentWarning: String?
|
||||
var attachments: [CompositionAttachment]
|
||||
private(set) var inReplyToID: String?
|
||||
private(set) var lastModified: Date
|
||||
|
||||
init(accountID: String, text: String, contentWarning: String?, inReplyToID: String?, attachments: [CompositionAttachment], lastModified: Date = Date()) {
|
||||
self.id = UUID()
|
||||
self.accountID = accountID
|
||||
self.text = text
|
||||
self.contentWarning = contentWarning
|
||||
self.inReplyToID = inReplyToID
|
||||
self.attachments = attachments
|
||||
self.lastModified = lastModified
|
||||
}
|
||||
|
||||
func update(accountID: String, text: String, contentWarning: String?, attachments: [CompositionAttachment]) {
|
||||
self.accountID = accountID
|
||||
self.text = text
|
||||
self.contentWarning = contentWarning
|
||||
self.lastModified = Date()
|
||||
self.attachments = attachments
|
||||
}
|
||||
|
||||
static func ==(lhs: Draft, rhs: Draft) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
//
|
||||
// StatusStateResolver.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 9/15/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Pachyderm
|
||||
|
||||
extension StatusState {
|
||||
|
||||
func resolveFor(status: StatusMO, text: String?) {
|
||||
let longEnoughToCollapse: Bool
|
||||
if Preferences.shared.collapseLongPosts,
|
||||
let text = text,
|
||||
text.count > 500 {
|
||||
longEnoughToCollapse = true
|
||||
} else {
|
||||
longEnoughToCollapse = false
|
||||
}
|
||||
|
||||
let contentWarningCollapsible = !status.spoilerText.isEmpty
|
||||
|
||||
self.collapsible = contentWarningCollapsible || longEnoughToCollapse
|
||||
self.collapsed = longEnoughToCollapse || (!Preferences.shared.expandAllContentWarnings && contentWarningCollapsible)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
//
|
||||
// UIAccessibility.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 9/12/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIAccessibility {
|
||||
|
||||
static var prefersCrossFadeTransitionsBackwardsCompat: Bool {
|
||||
if #available(iOS 14.0, *) {
|
||||
return prefersCrossFadeTransitions
|
||||
} else {
|
||||
return isReduceMotionEnabled
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
//
|
||||
// UIBezierPath+Helpers.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 6/25/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
// TODO: write unit tests for this
|
||||
extension UIBezierPath {
|
||||
|
||||
/// Create a new UIBezierPath that wraps around the given array of rectangles.
|
||||
/// This is not a convex hull aglorithm. What this does is it takes a set of rectangles
|
||||
/// and draws a line around the outer borders of the combined shape.
|
||||
convenience init(wrappingAround rects: [CGRect]) {
|
||||
precondition(rects.count > 0)
|
||||
|
||||
if rects.count == 1 {
|
||||
self.init(rect: rects.first!)
|
||||
return
|
||||
}
|
||||
|
||||
let rects = rects.sorted { $0.minY < $1.minY }
|
||||
|
||||
self.init()
|
||||
|
||||
// start at the top left corner
|
||||
self.move(to: CGPoint(x: rects.first!.minX, y: rects.first!.minY))
|
||||
|
||||
// walk down the left side
|
||||
var prevLeft = rects.first!.minX
|
||||
for rect in rects where !rect.minX.isEqual(to: prevLeft) {
|
||||
self.addLine(to: CGPoint(x: prevLeft, y: rect.minY))
|
||||
self.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
|
||||
prevLeft = rect.minX
|
||||
}
|
||||
|
||||
// ensure at the bottom left if not already
|
||||
let bottomLeft = CGPoint(x: rects.last!.minX, y: rects.last!.maxY)
|
||||
if !self.currentPoint.equalTo(bottomLeft) {
|
||||
self.addLine(to: bottomLeft)
|
||||
}
|
||||
|
||||
// across the bottom of the last rect
|
||||
self.addLine(to: CGPoint(x: rects.last!.maxX, y: rects.last!.maxY))
|
||||
|
||||
// walk up the right side
|
||||
var prevRight = rects.last!.maxX
|
||||
for rect in rects.reversed() where !rect.maxX.isEqual(to: prevRight) {
|
||||
self.addLine(to: CGPoint(x: prevRight, y: rect.maxY))
|
||||
self.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
|
||||
prevRight = rect.maxX
|
||||
}
|
||||
|
||||
// ensure at the top right if not already
|
||||
let topRight = CGPoint(x: rects.first!.maxX, y: rects.first!.minY)
|
||||
if !self.currentPoint.equalTo(topRight) {
|
||||
self.addLine(to: topRight)
|
||||
}
|
||||
|
||||
// across the top of the first rect
|
||||
self.addLine(to: CGPoint(x: rects.first!.minX, y: rects.first!.minY))
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
//
|
||||
// View+ConditionalModifier.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/31/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
|
||||
@ViewBuilder
|
||||
func conditionally<Modified: View>(_ condition: Bool, modifier: (Self) -> Modified) -> some View {
|
||||
if condition {
|
||||
modifier(self)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -31,6 +31,8 @@
|
|||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.social-networking</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
|
|
@ -18,6 +18,7 @@ class LocalData: ObservableObject {
|
|||
private init() {
|
||||
if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING") {
|
||||
defaults = UserDefaults(suiteName: "\(Bundle.main.bundleIdentifier!).uitesting")!
|
||||
defaults.removePersistentDomain(forName: "\(Bundle.main.bundleIdentifier!).uitesting")
|
||||
if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING_LOGIN") {
|
||||
accounts = [
|
||||
UserAccountInfo(
|
||||
|
@ -30,7 +31,20 @@ class LocalData: ObservableObject {
|
|||
]
|
||||
}
|
||||
} else {
|
||||
defaults = UserDefaults()
|
||||
defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")!
|
||||
tryMigrateOldDefaults()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: remove me before public beta
|
||||
private func tryMigrateOldDefaults() {
|
||||
let old = UserDefaults()
|
||||
if let accounts = old.array(forKey: accountsKey) as? [[String: String]],
|
||||
let mostRecentAccount = old.string(forKey: mostRecentAccountKey) {
|
||||
defaults.setValue(accounts, forKey: accountsKey)
|
||||
defaults.setValue(mostRecentAccount, forKey: mostRecentAccountKey)
|
||||
old.removeObject(forKey: accountsKey)
|
||||
old.removeObject(forKey: mostRecentAccountKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,22 +10,48 @@ import Foundation
|
|||
import UIKit
|
||||
import MobileCoreServices
|
||||
|
||||
final class CompositionAttachment: NSObject, Codable {
|
||||
final class CompositionAttachment: NSObject, Codable, ObservableObject {
|
||||
static let typeIdentifier = "space.vaccor.Tusker.composition-attachment"
|
||||
|
||||
let data: CompositionAttachmentData
|
||||
var attachmentDescription: String
|
||||
let id: UUID
|
||||
@Published var data: CompositionAttachmentData
|
||||
@Published var attachmentDescription: String
|
||||
|
||||
init(data: CompositionAttachmentData, description: String = "") {
|
||||
self.id = UUID()
|
||||
self.data = data
|
||||
self.attachmentDescription = description
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.id = try container.decode(UUID.self, forKey: .id)
|
||||
self.data = try container.decode(CompositionAttachmentData.self, forKey: .data)
|
||||
self.attachmentDescription = try container.decode(String.self, forKey: .attachmentDescription)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(data, forKey: .data)
|
||||
try container.encode(attachmentDescription, forKey: .attachmentDescription)
|
||||
}
|
||||
|
||||
static func ==(lhs: CompositionAttachment, rhs: CompositionAttachment) -> Bool {
|
||||
return lhs.data == rhs.data
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case data
|
||||
case attachmentDescription
|
||||
}
|
||||
}
|
||||
|
||||
extension CompositionAttachment: Identifiable {}
|
||||
|
||||
private let imageType = kUTTypeImage as String
|
||||
private let mp4Type = kUTTypeMPEG4 as String
|
||||
private let quickTimeType = kUTTypeQuickTimeMovie as String
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
//
|
||||
// CompositionState.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 1/17/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct CompositionState: OptionSet {
|
||||
let rawValue: Int
|
||||
|
||||
static let currentlyPosting = CompositionState(rawValue: 1 << 0)
|
||||
static let tooManyCharacters = CompositionState(rawValue: 1 << 1)
|
||||
static let requiresAttachmentDescriptions = CompositionState(rawValue: 1 << 2)
|
||||
|
||||
static let valid: CompositionState = []
|
||||
|
||||
var isValid: Bool {
|
||||
isEmpty
|
||||
}
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
//
|
||||
// Draft.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/18/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Pachyderm
|
||||
|
||||
class Draft: Codable, ObservableObject {
|
||||
let id: UUID
|
||||
var lastModified: Date
|
||||
|
||||
@Published var accountID: String
|
||||
@Published var text: String
|
||||
@Published var contentWarningEnabled: Bool
|
||||
@Published var contentWarning: String
|
||||
@Published var attachments: [CompositionAttachment]
|
||||
@Published var inReplyToID: String?
|
||||
@Published var visibility: Status.Visibility
|
||||
|
||||
var initialText: String
|
||||
|
||||
var hasContent: Bool {
|
||||
(!text.isEmpty && text != initialText) ||
|
||||
(contentWarningEnabled && !contentWarning.isEmpty) ||
|
||||
attachments.count > 0
|
||||
}
|
||||
|
||||
init(accountID: String) {
|
||||
self.id = UUID()
|
||||
self.lastModified = Date()
|
||||
|
||||
self.accountID = accountID
|
||||
self.text = ""
|
||||
self.contentWarningEnabled = false
|
||||
self.contentWarning = ""
|
||||
self.attachments = []
|
||||
self.inReplyToID = nil
|
||||
self.visibility = Preferences.shared.defaultPostVisibility
|
||||
|
||||
self.initialText = ""
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.id = try container.decode(UUID.self, forKey: .id)
|
||||
self.lastModified = try container.decode(Date.self, forKey: .lastModified)
|
||||
|
||||
self.accountID = try container.decode(String.self, forKey: .accountID)
|
||||
self.text = try container.decode(String.self, forKey: .text)
|
||||
if let enabled = try? container.decode(Bool.self, forKey: .contentWarningEnabled) {
|
||||
self.contentWarningEnabled = enabled
|
||||
self.contentWarning = try container.decode(String.self, forKey: .contentWarning)
|
||||
} else {
|
||||
// todo: temporary until migration away from old drafts manager is complete
|
||||
let cw = try container.decode(String?.self, forKey: .contentWarning)
|
||||
if let cw = cw {
|
||||
self.contentWarningEnabled = !cw.isEmpty
|
||||
self.contentWarning = cw
|
||||
} else {
|
||||
self.contentWarningEnabled = false
|
||||
self.contentWarning = ""
|
||||
}
|
||||
}
|
||||
self.attachments = try container.decode([CompositionAttachment].self, forKey: .attachments)
|
||||
self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID)
|
||||
self.visibility = try container.decode(Status.Visibility.self, forKey: .visibility)
|
||||
|
||||
self.initialText = try container.decode(String.self, forKey: .initialText)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(lastModified, forKey: .lastModified)
|
||||
|
||||
try container.encode(accountID, forKey: .accountID)
|
||||
try container.encode(text, forKey: .text)
|
||||
try container.encode(contentWarningEnabled, forKey: .contentWarningEnabled)
|
||||
try container.encode(contentWarning, forKey: .contentWarning)
|
||||
try container.encode(attachments, forKey: .attachments)
|
||||
try container.encode(inReplyToID, forKey: .inReplyToID)
|
||||
try container.encode(visibility, forKey: .visibility)
|
||||
|
||||
try container.encode(initialText, forKey: .initialText)
|
||||
}
|
||||
}
|
||||
|
||||
extension Draft: Equatable {
|
||||
static func ==(lhs: Draft, rhs: Draft) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
extension Draft {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case lastModified
|
||||
|
||||
case accountID
|
||||
case text
|
||||
case contentWarningEnabled
|
||||
case contentWarning
|
||||
case attachments
|
||||
case inReplyToID
|
||||
case visibility
|
||||
|
||||
case initialText
|
||||
}
|
||||
}
|
||||
|
||||
extension MastodonController {
|
||||
|
||||
func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil) -> Draft {
|
||||
var acctsToMention = [String]()
|
||||
|
||||
if let inReplyToID = inReplyToID,
|
||||
let inReplyTo = persistentContainer.status(for: inReplyToID) {
|
||||
acctsToMention.append(inReplyTo.account.acct)
|
||||
acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct))
|
||||
}
|
||||
if let mentioningAcct = mentioningAcct {
|
||||
acctsToMention.append(mentioningAcct)
|
||||
}
|
||||
if let ownAccount = self.account {
|
||||
acctsToMention.removeAll(where: { $0 == ownAccount.acct })
|
||||
}
|
||||
acctsToMention = acctsToMention.uniques()
|
||||
|
||||
let draft = Draft(accountID: accountInfo!.id)
|
||||
draft.inReplyToID = inReplyToID
|
||||
draft.text = acctsToMention.map { "@\($0) " }.joined()
|
||||
draft.initialText = draft.text
|
||||
return draft
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
//
|
||||
// DraftsManager.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 10/22/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class DraftsManager: Codable {
|
||||
|
||||
private(set) static var shared: DraftsManager = load()
|
||||
|
||||
private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
private static var archiveURL = DraftsManager.documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
|
||||
|
||||
static func save() {
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let encoder = PropertyListEncoder()
|
||||
let data = try? encoder.encode(shared)
|
||||
try? data?.write(to: archiveURL, options: .noFileProtection)
|
||||
}
|
||||
}
|
||||
|
||||
static func load() -> DraftsManager {
|
||||
let decoder = PropertyListDecoder()
|
||||
if let data = try? Data(contentsOf: archiveURL),
|
||||
let draftsManager = try? decoder.decode(DraftsManager.self, from: data) {
|
||||
return draftsManager
|
||||
}
|
||||
return DraftsManager()
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
var drafts: [Draft] = []
|
||||
var sorted: [Draft] {
|
||||
return drafts.sorted(by: { $0.lastModified > $1.lastModified })
|
||||
}
|
||||
|
||||
func add(_ draft: Draft) {
|
||||
drafts.append(draft)
|
||||
}
|
||||
|
||||
func remove(_ draft: Draft) {
|
||||
drafts.removeAll { $0 == draft }
|
||||
}
|
||||
|
||||
}
|
|
@ -38,7 +38,6 @@ class Preferences: Codable, ObservableObject {
|
|||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme)
|
||||
self.showRepliesInProfiles = try container.decode(Bool.self, forKey: .showRepliesInProfiles)
|
||||
self.avatarStyle = try container.decode(AvatarStyle.self, forKey: .avatarStyle)
|
||||
self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames)
|
||||
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
|
||||
|
@ -56,6 +55,12 @@ class Preferences: Codable, ObservableObject {
|
|||
self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
|
||||
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
|
||||
self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode)
|
||||
if container.contains(.expandAllContentWarnings) {
|
||||
self.expandAllContentWarnings = try container.decode(Bool.self, forKey: .expandAllContentWarnings)
|
||||
}
|
||||
if container.contains(.collapseLongPosts) {
|
||||
self.collapseLongPosts = try container.decode(Bool.self, forKey: .collapseLongPosts)
|
||||
}
|
||||
|
||||
self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
|
||||
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
|
||||
|
@ -68,7 +73,6 @@ class Preferences: Codable, ObservableObject {
|
|||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encode(theme, forKey: .theme)
|
||||
try container.encode(showRepliesInProfiles, forKey: .showRepliesInProfiles)
|
||||
try container.encode(avatarStyle, forKey: .avatarStyle)
|
||||
try container.encode(hideCustomEmojiInUsernames, forKey: .hideCustomEmojiInUsernames)
|
||||
try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon)
|
||||
|
@ -86,6 +90,8 @@ class Preferences: Codable, ObservableObject {
|
|||
try container.encode(openLinksInApps, forKey: .openLinksInApps)
|
||||
try container.encode(useInAppSafari, forKey: .useInAppSafari)
|
||||
try container.encode(inAppSafariAutomaticReaderMode, forKey: .inAppSafariAutomaticReaderMode)
|
||||
try container.encode(expandAllContentWarnings, forKey: .expandAllContentWarnings)
|
||||
try container.encode(collapseLongPosts, forKey: .collapseLongPosts)
|
||||
|
||||
try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts)
|
||||
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
|
||||
|
@ -96,7 +102,6 @@ class Preferences: Codable, ObservableObject {
|
|||
|
||||
// MARK: Appearance
|
||||
@Published var theme = UIUserInterfaceStyle.unspecified
|
||||
@Published var showRepliesInProfiles = false
|
||||
@Published var avatarStyle = AvatarStyle.roundRect
|
||||
@Published var hideCustomEmojiInUsernames = false
|
||||
@Published var showIsStatusReplyIcon = false
|
||||
|
@ -117,6 +122,8 @@ class Preferences: Codable, ObservableObject {
|
|||
@Published var openLinksInApps = true
|
||||
@Published var useInAppSafari = true
|
||||
@Published var inAppSafariAutomaticReaderMode = false
|
||||
@Published var expandAllContentWarnings = false
|
||||
@Published var collapseLongPosts = true
|
||||
|
||||
// MARK: Digital Wellness
|
||||
@Published var showFavoriteAndReblogCounts = true
|
||||
|
@ -128,7 +135,6 @@ class Preferences: Codable, ObservableObject {
|
|||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case theme
|
||||
case showRepliesInProfiles
|
||||
case avatarStyle
|
||||
case hideCustomEmojiInUsernames
|
||||
case showIsStatusReplyIcon
|
||||
|
@ -146,6 +152,8 @@ class Preferences: Codable, ObservableObject {
|
|||
case openLinksInApps
|
||||
case useInAppSafari
|
||||
case inAppSafariAutomaticReaderMode
|
||||
case expandAllContentWarnings
|
||||
case collapseLongPosts
|
||||
|
||||
case showFavoriteAndReblogCounts
|
||||
case defaultNotificationsType
|
||||
|
|
|
@ -157,8 +157,17 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
mastodonController.getOwnAccount()
|
||||
mastodonController.getOwnInstance()
|
||||
|
||||
let tabBarController = MainTabBarViewController(mastodonController: mastodonController)
|
||||
window!.rootViewController = tabBarController
|
||||
let rootController: UIViewController
|
||||
#if SDK_IOS_14
|
||||
if #available(iOS 14.0, *) {
|
||||
rootController = MainSplitViewController(mastodonController: mastodonController)
|
||||
} else {
|
||||
rootController = MainTabBarViewController(mastodonController: mastodonController)
|
||||
}
|
||||
#else
|
||||
rootController = MainTabBarViewController(mastodonController: mastodonController)
|
||||
#endif
|
||||
window!.rootViewController = rootController
|
||||
}
|
||||
|
||||
func showOnboardingUI() {
|
||||
|
|
|
@ -22,20 +22,22 @@ class AssetCollectionViewController: UICollectionViewController {
|
|||
|
||||
weak var delegate: AssetCollectionViewControllerDelegate?
|
||||
|
||||
var flowLayout: UICollectionViewFlowLayout {
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
private var flowLayout: UICollectionViewFlowLayout {
|
||||
return collectionViewLayout as! UICollectionViewFlowLayout
|
||||
}
|
||||
|
||||
var availableWidth: CGFloat!
|
||||
var thumbnailSize: CGSize!
|
||||
private var availableWidth: CGFloat!
|
||||
private var thumbnailSize: CGSize!
|
||||
|
||||
let imageManager = PHCachingImageManager()
|
||||
var fetchResult: PHFetchResult<PHAsset>!
|
||||
private let imageManager = PHCachingImageManager()
|
||||
private var fetchResult: PHFetchResult<PHAsset>!
|
||||
|
||||
var selectedAssets: [PHAsset] {
|
||||
return collectionView.indexPathsForSelectedItems?.map({ (indexPath) in
|
||||
fetchResult.object(at: indexPath.row - 1)
|
||||
}) ?? []
|
||||
return collectionView.indexPathsForSelectedItems?.compactMap { (indexPath) in
|
||||
guard case let .asset(asset) = dataSource.itemIdentifier(for: indexPath) else { return nil }
|
||||
return asset
|
||||
} ?? []
|
||||
}
|
||||
|
||||
init() {
|
||||
|
@ -71,14 +73,46 @@ class AssetCollectionViewController: UICollectionViewController {
|
|||
collectionView.register(UINib(nibName: "AssetCollectionViewCell", bundle: .main), forCellWithReuseIdentifier: reuseIdentifier)
|
||||
collectionView.register(UINib(nibName: "ShowCameraCollectionViewCell", bundle: .main), forCellWithReuseIdentifier: cameraReuseIdentifier)
|
||||
|
||||
let scale = UIScreen.main.scale
|
||||
let cellSize = flowLayout.itemSize
|
||||
thumbnailSize = CGSize(width: cellSize.width * scale, height: cellSize.height * scale)
|
||||
|
||||
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView, cellProvider: { (collectionView, indexPath, item) -> UICollectionViewCell? in
|
||||
switch item {
|
||||
case .showCamera:
|
||||
return collectionView.dequeueReusableCell(withReuseIdentifier: cameraReuseIdentifier, for: indexPath)
|
||||
case let .asset(asset):
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! AssetCollectionViewCell
|
||||
|
||||
cell.updateUI(asset: asset)
|
||||
self.imageManager.requestImage(for: asset, targetSize: self.thumbnailSize, contentMode: .aspectFill, options: nil) { (image, _) in
|
||||
guard let image = image else { return }
|
||||
DispatchQueue.main.async {
|
||||
guard cell.assetIdentifier == asset.localIdentifier else { return }
|
||||
cell.thumbnailImage = image
|
||||
}
|
||||
}
|
||||
|
||||
return cell
|
||||
}
|
||||
})
|
||||
|
||||
let options = PHFetchOptions()
|
||||
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
|
||||
fetchResult = fetchAssets(with: options)
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.assets])
|
||||
var items: [Item] = [.showCamera]
|
||||
fetchResult.enumerateObjects { (asset, _, _) in
|
||||
items.append(.asset(asset))
|
||||
}
|
||||
snapshot.appendItems(items)
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
|
||||
collectionView.allowsMultipleSelection = true
|
||||
setEditing(true, animated: false)
|
||||
|
||||
updateItemsSelected()
|
||||
updateItemsSelectedCount()
|
||||
|
||||
if let singleFingerPanGesture = collectionView.gestureRecognizers?.first(where: {
|
||||
$0.name == "multi-select.singleFingerPanGesture"
|
||||
|
@ -103,55 +137,16 @@ class AssetCollectionViewController: UICollectionViewController {
|
|||
}
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
let scale = UIScreen.main.scale
|
||||
let cellSize = flowLayout.itemSize
|
||||
thumbnailSize = CGSize(width: cellSize.width * scale, height: cellSize.height * scale)
|
||||
}
|
||||
|
||||
open func fetchAssets(with options: PHFetchOptions) -> PHFetchResult<PHAsset> {
|
||||
return PHAsset.fetchAssets(with: options)
|
||||
}
|
||||
|
||||
func updateItemsSelected() {
|
||||
func updateItemsSelectedCount() {
|
||||
let selected = collectionView.indexPathsForSelectedItems?.count ?? 0
|
||||
|
||||
navigationItem.title = "\(selected) selected"
|
||||
}
|
||||
|
||||
// MARK: UICollectionViewDataSource
|
||||
|
||||
override func numberOfSections(in collectionView: UICollectionView) -> Int {
|
||||
return 1
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
return fetchResult.count + 1
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
if indexPath.row == 0 {
|
||||
return collectionView.dequeueReusableCell(withReuseIdentifier: cameraReuseIdentifier, for: indexPath)
|
||||
} else {
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! AssetCollectionViewCell
|
||||
|
||||
let asset = fetchResult.object(at: indexPath.row - 1)
|
||||
|
||||
cell.updateUI(asset: asset)
|
||||
imageManager.requestImage(for: asset, targetSize: thumbnailSize, contentMode: .aspectFill, options: nil) { (image, _) in
|
||||
guard let image = image else { return }
|
||||
DispatchQueue.main.async {
|
||||
guard cell.assetIdentifier == asset.localIdentifier else { return }
|
||||
cell.thumbnailImage = image
|
||||
}
|
||||
}
|
||||
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UICollectionViewDelegate
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, shouldBeginMultipleSelectionInteractionAt indexPath: IndexPath) -> Bool {
|
||||
|
@ -159,37 +154,35 @@ class AssetCollectionViewController: UICollectionViewController {
|
|||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
|
||||
if indexPath.row > 0,
|
||||
let delegate = delegate {
|
||||
let asset = fetchResult.object(at: indexPath.row - 1)
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return false }
|
||||
if let delegate = delegate,
|
||||
case let .asset(asset) = item {
|
||||
return delegate.shouldSelectAsset(asset)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
if indexPath.row == 0 {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
switch item {
|
||||
case .showCamera:
|
||||
collectionView.deselectItem(at: indexPath, animated: false)
|
||||
delegate?.captureFromCamera()
|
||||
} else {
|
||||
updateItemsSelected()
|
||||
case .asset(_):
|
||||
updateItemsSelectedCount()
|
||||
}
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
|
||||
updateItemsSelected()
|
||||
updateItemsSelectedCount()
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
if indexPath.row == 0 {
|
||||
return nil
|
||||
} else {
|
||||
let asset = fetchResult.object(at: indexPath.row - 1)
|
||||
guard case let .asset(asset) = dataSource.itemIdentifier(for: indexPath) else { return nil }
|
||||
return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: { () -> UIViewController? in
|
||||
return AssetPreviewViewController(asset: asset)
|
||||
}, actionProvider: nil)
|
||||
}
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
if let indexPath = (configuration.identifier as? NSIndexPath) as IndexPath?,
|
||||
|
@ -210,3 +203,13 @@ class AssetCollectionViewController: UICollectionViewController {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
extension AssetCollectionViewController {
|
||||
enum Section: Hashable {
|
||||
case assets
|
||||
}
|
||||
enum Item: Hashable {
|
||||
case showCamera
|
||||
case asset(PHAsset)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,14 +9,14 @@
|
|||
import UIKit
|
||||
import Photos
|
||||
|
||||
protocol AssetPickerViewControllerDelegate {
|
||||
protocol AssetPickerViewControllerDelegate: class {
|
||||
func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool
|
||||
func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachmentData])
|
||||
}
|
||||
|
||||
class AssetPickerViewController: UINavigationController {
|
||||
|
||||
var assetPickerDelegate: AssetPickerViewControllerDelegate?
|
||||
weak var assetPickerDelegate: AssetPickerViewControllerDelegate?
|
||||
|
||||
var currentCollectionSelectedAssets: [CompositionAttachmentData] {
|
||||
if let vc = visibleViewController as? AssetCollectionViewController {
|
||||
|
|
|
@ -27,6 +27,10 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
|
|||
}
|
||||
|
||||
var animationSourceView: UIImageView? { sourceViews[currentIndex] }
|
||||
var largeImageController: LargeImageViewController? {
|
||||
// use protocol because page controllers may be loading or non-loading VCs
|
||||
(pages[currentIndex] as? LargeImageAnimatableViewController)?.largeImageController
|
||||
}
|
||||
var animationImage: UIImage? {
|
||||
if let page = pages[currentIndex] as? LargeImageAnimatableViewController,
|
||||
let image = page.animationImage {
|
||||
|
@ -48,6 +52,9 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
|
|||
override var prefersStatusBarHidden: Bool {
|
||||
return true
|
||||
}
|
||||
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
|
||||
return .none
|
||||
}
|
||||
override var childForHomeIndicatorAutoHidden: UIViewController? {
|
||||
return viewControllers?.first
|
||||
}
|
||||
|
@ -70,6 +77,7 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
|
|||
case .image:
|
||||
let vc = LoadingLargeImageViewController(attachment: attachment)
|
||||
vc.shrinkGestureEnabled = false
|
||||
vc.animationSourceView = sourceViews[index]
|
||||
return vc
|
||||
case .video, .audio:
|
||||
let vc = GalleryPlayerViewController()
|
||||
|
|
|
@ -149,20 +149,6 @@ class BookmarksTableViewController: EnhancedTableViewController {
|
|||
return config
|
||||
}
|
||||
|
||||
override func getSuggestedContextMenuActions(tableView: UITableView, indexPath: IndexPath, point: CGPoint) -> [UIAction] {
|
||||
guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else { return [] }
|
||||
return [
|
||||
UIAction(title: NSLocalizedString("Unbookmark", comment: "unbookmark action title"), image: UIImage(systemName: "bookmark.fill"), identifier: .init("unbookmark"), discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in
|
||||
let request = Status.unbookmark(status.id)
|
||||
self.mastodonController.run(request) { (response) in
|
||||
guard case let .success(newStatus, _) = response else { fatalError() }
|
||||
self.mastodonController.persistentContainer.addOrUpdate(status: newStatus, incrementReferenceCount: false)
|
||||
self.statuses.remove(at: indexPath.row)
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension BookmarksTableViewController: StatusTableViewCellDelegate {
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
//
|
||||
// ComposeAssetPicker.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/19/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ComposeAssetPicker: UIViewControllerRepresentable {
|
||||
typealias UIViewControllerType = AssetPickerViewController
|
||||
|
||||
@ObservedObject var draft: Draft
|
||||
let delegate: AssetPickerViewControllerDelegate?
|
||||
|
||||
@EnvironmentObject var mastodonController: MastodonController
|
||||
|
||||
func makeUIViewController(context: Context) -> AssetPickerViewController {
|
||||
let vc = AssetPickerViewController()
|
||||
vc.assetPickerDelegate = delegate
|
||||
vc.preferredContentSize = CGSize(width: 400, height: 600)
|
||||
return vc
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: AssetPickerViewController, context: Context) {
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,206 @@
|
|||
//
|
||||
// ComposeAttachmentRow.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/19/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Photos
|
||||
import AVFoundation
|
||||
import Vision
|
||||
|
||||
struct ComposeAttachmentRow: View {
|
||||
@ObservedObject var draft: Draft
|
||||
@ObservedObject var attachment: CompositionAttachment
|
||||
let heightChanged: (CGFloat) -> Void
|
||||
|
||||
@EnvironmentObject var uiState: ComposeUIState
|
||||
@State private var mode: Mode = .allowEntry
|
||||
@State private var image: UIImage? = nil
|
||||
@State private var imageContentMode: ContentMode = .fill
|
||||
@State private var imageBackgroundColor: Color = .black
|
||||
@State private var isShowingTextRecognitionFailedAlert = false
|
||||
@State private var textRecognitionErrorMessage: String? = nil
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
imageView
|
||||
.frame(width: 80, height: 80)
|
||||
.cornerRadius(8)
|
||||
.contextMenu {
|
||||
if case .drawing(_) = attachment.data {
|
||||
Button(action: self.editDrawing) {
|
||||
if #available(iOS 14.0, *) {
|
||||
Label("Edit Drawing", systemImage: "hand.draw")
|
||||
} else {
|
||||
HStack {
|
||||
Text("Edit Drawing")
|
||||
Image(systemName: "hand.draw")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if attachment.data.type == .image {
|
||||
Button(action: self.recognizeText) {
|
||||
if #available(iOS 14.0, *) {
|
||||
Label("Recognize Text", systemImage: "doc.text.viewfinder")
|
||||
} else {
|
||||
HStack {
|
||||
Text("Recognize Text")
|
||||
Image(systemName: "doc.text.viewfinder")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case .allowEntry:
|
||||
ComposeTextView(text: $attachment.attachmentDescription, placeholder: Text("Describe for the visually impaired…"), minHeight: 80)
|
||||
.heightDidChange(self.heightChanged)
|
||||
.backgroundColor(.clear)
|
||||
.fontSize(17)
|
||||
|
||||
case .recognizingText:
|
||||
if #available(iOS 14.0, *) {
|
||||
ProgressView()
|
||||
} else {
|
||||
ActivityIndicatorView()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// todo: find a way to make this button not activated when the list row is selected, see FB8595628
|
||||
// Button(action: self.removeAttachment) {
|
||||
// Image(systemName: "xmark.circle.fill")
|
||||
// .foregroundColor(.blue)
|
||||
// }
|
||||
}
|
||||
.onAppear(perform: self.loadImage)
|
||||
.onReceive(attachment.$attachmentDescription) { (newDesc) in
|
||||
if newDesc.isEmpty {
|
||||
uiState.attachmentsMissingDescriptions.insert(attachment.id)
|
||||
} else {
|
||||
uiState.attachmentsMissingDescriptions.remove(attachment.id)
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $isShowingTextRecognitionFailedAlert) {
|
||||
Alert(
|
||||
title: Text("Text Recognition Failed"),
|
||||
message: Text(self.textRecognitionErrorMessage ?? ""),
|
||||
dismissButton: .default(Text("OK"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var imageView: some View {
|
||||
if let image = image {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: imageContentMode)
|
||||
.background(imageBackgroundColor)
|
||||
} else {
|
||||
Image(systemName: placeholderImageName)
|
||||
}
|
||||
}
|
||||
|
||||
private var placeholderImageName: String {
|
||||
switch colorScheme {
|
||||
case .dark:
|
||||
return "photo.fill"
|
||||
case .light:
|
||||
return "photo"
|
||||
@unknown default:
|
||||
return "photo"
|
||||
}
|
||||
}
|
||||
|
||||
private func loadImage() {
|
||||
switch attachment.data {
|
||||
case let .image(image):
|
||||
self.image = image
|
||||
case let .asset(asset):
|
||||
let size = CGSize(width: 80, height: 80)
|
||||
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in
|
||||
DispatchQueue.main.async {
|
||||
self.image = image
|
||||
}
|
||||
}
|
||||
case let .video(url):
|
||||
let asset = AVURLAsset(url: url)
|
||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
||||
self.image = UIImage(cgImage: cgImage)
|
||||
}
|
||||
case let .drawing(drawing):
|
||||
image = drawing.imageInLightMode(from: drawing.bounds)
|
||||
imageContentMode = .fit
|
||||
imageBackgroundColor = .white
|
||||
}
|
||||
}
|
||||
|
||||
private func removeAttachment() {
|
||||
draft.attachments.removeAll { $0.id == attachment.id }
|
||||
}
|
||||
|
||||
private func editDrawing() {
|
||||
uiState.composeDrawingMode = .edit(id: attachment.id)
|
||||
uiState.delegate?.presentComposeDrawing()
|
||||
}
|
||||
|
||||
private func recognizeText() {
|
||||
mode = .recognizingText
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
self.attachment.data.getData { (data, mimeType) in
|
||||
let handler = VNImageRequestHandler(data: data, options: [:])
|
||||
let request = VNRecognizeTextRequest { (request, error) in
|
||||
DispatchQueue.main.async {
|
||||
if let results = request.results as? [VNRecognizedTextObservation] {
|
||||
var text = ""
|
||||
for observation in results {
|
||||
let result = observation.topCandidates(1).first!
|
||||
text.append(result.string)
|
||||
text.append("\n")
|
||||
}
|
||||
self.attachment.attachmentDescription = text
|
||||
}
|
||||
|
||||
self.mode = .allowEntry
|
||||
}
|
||||
}
|
||||
request.recognitionLevel = .accurate
|
||||
request.usesLanguageCorrection = true
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do {
|
||||
try handler.perform([request])
|
||||
} catch {
|
||||
// The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for.
|
||||
guard (error as NSError).code != 1 else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.mode = .allowEntry
|
||||
self.isShowingTextRecognitionFailedAlert = true
|
||||
self.textRecognitionErrorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeAttachmentRow {
|
||||
enum Mode {
|
||||
case allowEntry, recognizingText
|
||||
}
|
||||
}
|
||||
|
||||
//struct ComposeAttachmentRow_Previews: PreviewProvider {
|
||||
// static var previews: some View {
|
||||
// ComposeAttachmentRow()
|
||||
// }
|
||||
//}
|
|
@ -0,0 +1,174 @@
|
|||
//
|
||||
// ComposeAttachmentsList.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/19/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ComposeAttachmentsList: View {
|
||||
private let cellHeight: CGFloat = 80
|
||||
private let cellPadding: CGFloat = 12
|
||||
|
||||
@ObservedObject var draft: Draft
|
||||
|
||||
@EnvironmentObject var mastodonController: MastodonController
|
||||
@EnvironmentObject var uiState: ComposeUIState
|
||||
@State var isShowingAssetPickerPopover = false
|
||||
@State var isShowingCreateDrawing = false
|
||||
@State var rowHeights = [UUID: CGFloat]()
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
||||
@Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(draft.attachments) { (attachment) in
|
||||
ComposeAttachmentRow(
|
||||
draft: draft,
|
||||
attachment: attachment
|
||||
) { (newHeight) in
|
||||
// in case height changed callback is called after atachment is removed but before view hierarchy is updated
|
||||
if draft.attachments.contains(where: { $0.id == attachment.id }) {
|
||||
rowHeights[attachment.id] = newHeight
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||
}
|
||||
.onMove(perform: self.moveAttachments)
|
||||
.onDelete(perform: self.deleteAttachments)
|
||||
.conditionally(canAddAttachment) {
|
||||
$0.onInsert(of: CompositionAttachment.readableTypeIdentifiersForItemProvider, perform: self.insertAttachments)
|
||||
}
|
||||
|
||||
Button(action: self.addAttachment) {
|
||||
if #available(iOS 14.0, *) {
|
||||
Label("Add photo or video", systemImage: addButtonImageName)
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: addButtonImageName)
|
||||
Text("Add photo or video")
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(!canAddAttachment)
|
||||
.foregroundColor(.blue)
|
||||
.frame(height: cellHeight / 2)
|
||||
.popover(isPresented: $isShowingAssetPickerPopover, content: self.assetPickerPopover)
|
||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||
|
||||
Button(action: self.createDrawing) {
|
||||
if #available(iOS 14.0, *) {
|
||||
Label("Draw something", systemImage: "hand.draw")
|
||||
} else {
|
||||
HStack(alignment: .lastTextBaseline) {
|
||||
Image(systemName: "hand.draw")
|
||||
Text("Draw something")
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(!canAddAttachment)
|
||||
.foregroundColor(.blue)
|
||||
.frame(height: cellHeight / 2)
|
||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||
}
|
||||
.frame(height: totalListHeight)
|
||||
.onAppear(perform: self.didAppear)
|
||||
.onReceive(draft.$attachments, perform: self.attachmentsChanged)
|
||||
}
|
||||
|
||||
private var addButtonImageName: String {
|
||||
switch colorScheme {
|
||||
case .dark:
|
||||
return "photo.fill"
|
||||
case .light:
|
||||
return "photo"
|
||||
@unknown default:
|
||||
return "photo"
|
||||
}
|
||||
}
|
||||
|
||||
private var canAddAttachment: Bool {
|
||||
switch mastodonController.instance.instanceType {
|
||||
case .pleroma:
|
||||
return true
|
||||
case .mastodon:
|
||||
return draft.attachments.count < 4 && draft.attachments.allSatisfy { $0.data.type == .image }
|
||||
}
|
||||
}
|
||||
|
||||
private var totalListHeight: CGFloat {
|
||||
let totalRowHeights = rowHeights.values.reduce(0, +)
|
||||
let totalPadding = CGFloat(draft.attachments.count) * cellPadding
|
||||
let addButtonHeight = cellHeight + cellPadding * 2
|
||||
return totalRowHeights + totalPadding + addButtonHeight
|
||||
}
|
||||
|
||||
private func didAppear() {
|
||||
let proxy = UITableView.appearance(whenContainedInInstancesOf: [ComposeHostingController.self])
|
||||
// enable drag and drop to reorder on iPhone
|
||||
proxy.dragInteractionEnabled = true
|
||||
}
|
||||
|
||||
private func attachmentsChanged(attachments: [CompositionAttachment]) {
|
||||
var copy = rowHeights
|
||||
for k in copy.keys where !attachments.contains(where: { k == $0.id }) {
|
||||
copy.removeValue(forKey: k)
|
||||
}
|
||||
for attachment in attachments where !copy.keys.contains(attachment.id) {
|
||||
copy[attachment.id] = cellHeight
|
||||
}
|
||||
self.rowHeights = copy
|
||||
}
|
||||
|
||||
private func assetPickerPopover() -> some View {
|
||||
ComposeAssetPicker(draft: draft, delegate: uiState.delegate?.assetPickerDelegate)
|
||||
.onDisappear {
|
||||
self.isShowingAssetPickerPopover = false
|
||||
}
|
||||
.environment(\.colorScheme, .dark)
|
||||
.edgesIgnoringSafeArea(.bottom)
|
||||
}
|
||||
|
||||
private func addAttachment() {
|
||||
if horizontalSizeClass == .regular {
|
||||
isShowingAssetPickerPopover = true
|
||||
} else {
|
||||
uiState.delegate?.presentAssetPickerSheet()
|
||||
}
|
||||
}
|
||||
|
||||
private func moveAttachments(from source: IndexSet, to destination: Int) {
|
||||
draft.attachments.move(fromOffsets: source, toOffset: destination)
|
||||
}
|
||||
|
||||
private func deleteAttachments(at indices: IndexSet) {
|
||||
draft.attachments.remove(atOffsets: indices)
|
||||
}
|
||||
|
||||
private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) {
|
||||
for provider in itemProviders where provider.canLoadObject(ofClass: CompositionAttachment.self) {
|
||||
guard canAddAttachment else { break }
|
||||
|
||||
provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in
|
||||
guard let attachment = object as? CompositionAttachment else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.draft.attachments.insert(attachment, at: offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createDrawing() {
|
||||
uiState.composeDrawingMode = .createNew
|
||||
uiState.delegate?.presentComposeDrawing()
|
||||
}
|
||||
}
|
||||
|
||||
//struct ComposeAttachmentsList_Previews: PreviewProvider {
|
||||
// static var previews: some View {
|
||||
// ComposeAttachmentsList()
|
||||
// }
|
||||
//}
|
|
@ -1,563 +0,0 @@
|
|||
//
|
||||
// ComposeAttachmentsViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 3/11/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import MobileCoreServices
|
||||
import PencilKit
|
||||
import Photos
|
||||
|
||||
protocol ComposeAttachmentsViewControllerDelegate: class {
|
||||
func composeSelectedAttachmentsDidChange()
|
||||
func composeRequiresAttachmentDescriptionsDidChange()
|
||||
}
|
||||
|
||||
class ComposeAttachmentsViewController: UITableViewController {
|
||||
|
||||
weak var mastodonController: MastodonController!
|
||||
weak var delegate: ComposeAttachmentsViewControllerDelegate?
|
||||
|
||||
private var heightConstraint: NSLayoutConstraint!
|
||||
|
||||
var attachments: [CompositionAttachment] = [] {
|
||||
didSet {
|
||||
delegate?.composeSelectedAttachmentsDidChange()
|
||||
delegate?.composeRequiresAttachmentDescriptionsDidChange()
|
||||
updateAddAttachmentsButtonEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
var requiresAttachmentDescriptions: Bool {
|
||||
if Preferences.shared.requireAttachmentDescriptions {
|
||||
return attachments.contains { $0.attachmentDescription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var currentlyEditedDrawingIndex: Int?
|
||||
|
||||
init(attachments: [CompositionAttachment], mastodonController: MastodonController) {
|
||||
self.attachments = attachments
|
||||
self.mastodonController = mastodonController
|
||||
super.init(style: .plain)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 96
|
||||
|
||||
tableView.register(UINib(nibName: "AddAttachmentTableViewCell", bundle: .main), forCellReuseIdentifier: "addAttachment")
|
||||
tableView.register(UINib(nibName: "ComposeAttachmentTableViewCell", bundle: .main), forCellReuseIdentifier: "composeAttachment")
|
||||
|
||||
// you would think the table view could handle this itself, but no, using a constraint on the table view's contentLayoutGuide doesn't work
|
||||
// add extra space, so when dropping items, the add attachment cell doesn't disappear
|
||||
heightConstraint = tableView.heightAnchor.constraint(equalToConstant: tableView.contentSize.height + 80)
|
||||
heightConstraint.isActive = true
|
||||
|
||||
// prevents extra separator lines from appearing when the height of the table view is greater than the height of the content
|
||||
tableView.tableFooterView = UIView()
|
||||
|
||||
pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self)
|
||||
|
||||
// enable dragging on iPhone to allow reordering
|
||||
tableView.dragInteractionEnabled = true
|
||||
tableView.dragDelegate = self
|
||||
tableView.dropDelegate = self
|
||||
|
||||
if mastodonController.instance == nil {
|
||||
mastodonController.getOwnInstance { [weak self] (_) in
|
||||
guard let self = self else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.updateAddAttachmentsButtonEnabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
updateHeightConstraint()
|
||||
}
|
||||
|
||||
func setAttachments(_ attachments: [CompositionAttachment]) {
|
||||
tableView.performBatchUpdates({
|
||||
tableView.deleteRows(at: self.attachments.indices.map { IndexPath(row: $0, section: 0) }, with: .automatic)
|
||||
self.attachments = attachments
|
||||
tableView.insertRows(at: self.attachments.indices.map { IndexPath(row: $0, section: 0) }, with: .automatic)
|
||||
})
|
||||
updateHeightConstraint()
|
||||
delegate?.composeRequiresAttachmentDescriptionsDidChange()
|
||||
}
|
||||
|
||||
private func updateHeightConstraint() {
|
||||
// add extra space, so when dropping items, the add attachment cell doesn't disappear
|
||||
heightConstraint.constant = tableView.contentSize.height + 80
|
||||
}
|
||||
|
||||
private func isAddAttachmentsButtonEnabled() -> Bool {
|
||||
switch mastodonController.instance?.instanceType {
|
||||
case nil:
|
||||
return false
|
||||
case .pleroma:
|
||||
return true
|
||||
case .mastodon:
|
||||
return !attachments.contains(where: { $0.data.type == .video }) && attachments.count < 4
|
||||
}
|
||||
}
|
||||
|
||||
private func updateAddAttachmentsButtonEnabled() {
|
||||
guard let cell = tableView.cellForRow(at: IndexPath(row: 0, section: 1)) as? AddAttachmentTableViewCell else { return }
|
||||
cell.setEnabled(isAddAttachmentsButtonEnabled())
|
||||
}
|
||||
|
||||
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
|
||||
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else {
|
||||
return false
|
||||
}
|
||||
|
||||
switch mastodonController.instance.instanceType {
|
||||
case .pleroma:
|
||||
return true
|
||||
case .mastodon:
|
||||
return itemProviders.count + attachments.count <= 4
|
||||
}
|
||||
}
|
||||
|
||||
override func paste(itemProviders: [NSItemProvider]) {
|
||||
for provider in itemProviders {
|
||||
provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in
|
||||
if let error = error {
|
||||
fatalError("Couldn't load image from NSItemProvider: \(error)")
|
||||
}
|
||||
guard let attachment = object as? CompositionAttachment else {
|
||||
fatalError("Couldn't convert object from NSItemProvider to CompositionAttachment")
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.attachments.append(attachment)
|
||||
self.tableView.insertRows(at: [IndexPath(row: self.attachments.count - 1, section: 0)], with: .automatic)
|
||||
self.updateHeightConstraint()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func presentComposeDrawingViewController(editingAttachmentAt attachmentIndex: Int? = nil) {
|
||||
let drawingVC: ComposeDrawingViewController
|
||||
|
||||
if let index = attachmentIndex,
|
||||
case let .drawing(drawing) = attachments[index].data {
|
||||
drawingVC = ComposeDrawingViewController(editing: drawing)
|
||||
currentlyEditedDrawingIndex = index
|
||||
} else {
|
||||
drawingVC = ComposeDrawingViewController()
|
||||
}
|
||||
|
||||
drawingVC.delegate = self
|
||||
let nav = UINavigationController(rootViewController: drawingVC)
|
||||
nav.modalPresentationStyle = .fullScreen
|
||||
present(nav, animated: true)
|
||||
}
|
||||
|
||||
func uploadAll(stepProgress: @escaping () -> Void, completion: @escaping (_ success: Bool, _ uploadedAttachments: [Attachment]) -> Void) {
|
||||
let group = DispatchGroup()
|
||||
|
||||
var anyFailed = false
|
||||
var uploadedAttachments: [Result<Attachment, Error>?] = []
|
||||
|
||||
for (index, compAttachment) in attachments.enumerated() {
|
||||
group.enter()
|
||||
|
||||
uploadedAttachments.append(nil)
|
||||
|
||||
compAttachment.data.getData { (data, mimeType) in
|
||||
stepProgress()
|
||||
|
||||
let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: "file")
|
||||
let request = Client.upload(attachment: formAttachment, description: compAttachment.attachmentDescription)
|
||||
self.mastodonController.run(request) { (response) in
|
||||
switch response {
|
||||
case let .failure(error):
|
||||
uploadedAttachments[index] = .failure(error)
|
||||
anyFailed = true
|
||||
case let .success(attachment, _):
|
||||
uploadedAttachments[index] = .success(attachment)
|
||||
}
|
||||
|
||||
stepProgress()
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: .main) {
|
||||
if anyFailed {
|
||||
let errors: [(Int, Error)] = uploadedAttachments.enumerated().compactMap { (index, result) in
|
||||
switch result {
|
||||
case let .failure(error):
|
||||
return (index, error)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
||||
let title: String
|
||||
var message: String
|
||||
if errors.count == 1 {
|
||||
title = NSLocalizedString("Could not upload attachment", comment: "single attachment upload failed alert title")
|
||||
message = errors[0].1.localizedDescription
|
||||
} else {
|
||||
title = NSLocalizedString("Could not upload the following attachments", comment: "multiple attachment upload failures alert title")
|
||||
message = ""
|
||||
for (index, error) in errors {
|
||||
message.append("Attachment \(index + 1): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: { (_) in
|
||||
completion(false, [])
|
||||
}))
|
||||
} else {
|
||||
let uploadedAttachments: [Attachment] = uploadedAttachments.compactMap {
|
||||
switch $0 {
|
||||
case let .success(attachment):
|
||||
return attachment
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
completion(true, uploadedAttachments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Table view data source
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return 2
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
switch section {
|
||||
case 0:
|
||||
return attachments.count
|
||||
case 1:
|
||||
return 1
|
||||
default:
|
||||
fatalError("invalid section \(section)")
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
switch indexPath.section {
|
||||
case 0:
|
||||
let attachment = attachments[indexPath.row]
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "composeAttachment", for: indexPath) as! ComposeAttachmentTableViewCell
|
||||
cell.delegate = self
|
||||
cell.updateUI(for: attachment)
|
||||
cell.setEnabled(true)
|
||||
return cell
|
||||
case 1:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "addAttachment", for: indexPath) as! AddAttachmentTableViewCell
|
||||
cell.setEnabled(isAddAttachmentsButtonEnabled())
|
||||
return cell
|
||||
default:
|
||||
fatalError("invalid section \(indexPath.section)")
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
|
||||
guard sourceIndexPath != destinationIndexPath, sourceIndexPath.section == 0, destinationIndexPath.section == 0 else { return }
|
||||
|
||||
attachments.insert(attachments.remove(at: sourceIndexPath.row), at: destinationIndexPath.row)
|
||||
}
|
||||
|
||||
// MARK: Table view delegate
|
||||
|
||||
override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
|
||||
if indexPath.section == 1, isAddAttachmentsButtonEnabled() {
|
||||
return indexPath
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
|
||||
if indexPath.section == 1 {
|
||||
addAttachmentPressed()
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
if indexPath.section == 0 {
|
||||
let attachment = attachments[indexPath.row]
|
||||
// cast to NSIndexPath because identifier needs to conform to NSCopying
|
||||
return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: { () -> UIViewController? in
|
||||
return AssetPreviewViewController(attachment: attachment.data)
|
||||
}) { (_) -> UIMenu? in
|
||||
var actions = [UIAction]()
|
||||
|
||||
switch attachment.data {
|
||||
case .drawing(_):
|
||||
actions.append(UIAction(title: "Edit Drawing", image: UIImage(systemName: "hand.draw"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in
|
||||
self.presentComposeDrawingViewController(editingAttachmentAt: indexPath.row)
|
||||
}))
|
||||
case .asset(_), .image(_):
|
||||
if attachment.data.type == .image,
|
||||
let cell = tableView.cellForRow(at: indexPath) as? ComposeAttachmentTableViewCell {
|
||||
|
||||
let title = NSLocalizedString("Recognize Text", comment: "recognize image attachment text menu item title")
|
||||
actions.append(UIAction(title: title, image: UIImage(systemName: "doc.text.viewfinder"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in
|
||||
cell.recognizeTextFromImage()
|
||||
}))
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if actions.isEmpty {
|
||||
return nil
|
||||
} else {
|
||||
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
|
||||
}
|
||||
}
|
||||
} else if indexPath.section == 1 {
|
||||
guard isAddAttachmentsButtonEnabled() else {
|
||||
return nil
|
||||
}
|
||||
// show context menu for drawing/file uploads
|
||||
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) -> UIMenu? in
|
||||
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: [
|
||||
UIAction(title: "Draw Something", image: UIImage(systemName: "hand.draw"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in
|
||||
self.presentComposeDrawingViewController()
|
||||
})
|
||||
])
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func targetedPreview(forConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
if let indexPath = (configuration.identifier as? NSIndexPath) as IndexPath?,
|
||||
indexPath.section == 0,
|
||||
let cell = tableView.cellForRow(at: indexPath) as? ComposeAttachmentTableViewCell {
|
||||
let parameters = UIPreviewParameters()
|
||||
parameters.backgroundColor = .black
|
||||
return UITargetedPreview(view: cell.assetImageView, parameters: parameters)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return targetedPreview(forConfiguration: configuration)
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return targetedPreview(forConfiguration: configuration)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
||||
func addAttachmentPressed() {
|
||||
PHPhotoLibrary.requestAuthorization { (status) in
|
||||
guard status == .authorized else { return }
|
||||
DispatchQueue.main.async {
|
||||
if self.traitCollection.horizontalSizeClass == .compact {
|
||||
let sheetContainer = AssetPickerSheetContainerViewController()
|
||||
sheetContainer.assetPicker.assetPickerDelegate = self
|
||||
self.present(sheetContainer, animated: true)
|
||||
} else {
|
||||
let picker = AssetPickerViewController()
|
||||
picker.assetPickerDelegate = self
|
||||
picker.overrideUserInterfaceStyle = .dark
|
||||
picker.modalPresentationStyle = .popover
|
||||
self.present(picker, animated: true)
|
||||
if let presentationController = picker.presentationController as? UIPopoverPresentationController {
|
||||
presentationController.sourceView = self.tableView.cellForRow(at: IndexPath(row: 0, section: 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ComposeAttachmentsViewController: UITableViewDragDelegate {
|
||||
func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
|
||||
guard indexPath.section == 0 else { return [] }
|
||||
|
||||
let attachment = attachments[indexPath.row]
|
||||
let provider = NSItemProvider(object: attachment)
|
||||
let dragItem = UIDragItem(itemProvider: provider)
|
||||
dragItem.localObject = attachment
|
||||
return [dragItem]
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, itemsForAddingTo session: UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem] {
|
||||
guard indexPath.section == 0 else { return [] }
|
||||
|
||||
let attachment = attachments[indexPath.row]
|
||||
let provider = NSItemProvider(object: attachment)
|
||||
let dragItem = UIDragItem(itemProvider: provider)
|
||||
dragItem.localObject = attachment
|
||||
return [dragItem]
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, dragPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters? {
|
||||
guard indexPath.section == 0 else { return nil }
|
||||
|
||||
let cell = tableView.cellForRow(at: indexPath) as! ComposeAttachmentTableViewCell
|
||||
let rect = cell.convert(cell.assetImageView.bounds, from: cell.assetImageView)
|
||||
let path = UIBezierPath(roundedRect: rect, cornerRadius: cell.assetImageView.layer.cornerRadius)
|
||||
let params = UIDragPreviewParameters()
|
||||
params.visiblePath = path
|
||||
return params
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeAttachmentsViewController: UITableViewDropDelegate {
|
||||
func tableView(_ tableView: UITableView, canHandle session: UIDropSession) -> Bool {
|
||||
return session.canLoadObjects(ofClass: CompositionAttachment.self)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
|
||||
// if items were dragged out of ourself, then the items are only being moved
|
||||
if tableView.hasActiveDrag {
|
||||
// todo: should moving multiple items actually be prohibited?
|
||||
if session.items.count > 1 {
|
||||
return UITableViewDropProposal(operation: .cancel)
|
||||
} else {
|
||||
return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
|
||||
}
|
||||
} else {
|
||||
return UITableViewDropProposal(operation: .copy, intent: .insertAtDestinationIndexPath)
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
|
||||
let destinationIndexPath = coordinator.destinationIndexPath ?? IndexPath(row: attachments.count, section: 0)
|
||||
|
||||
// we don't need to handle local items here, when the .move operation is used returned from the tableView(_:dropSessionDidUpdate:withDestinationIndexPath:) method,
|
||||
// the table view will handle animating and call the normal data source tableView(_:moveRowAt:to:)
|
||||
|
||||
for (index, item) in coordinator.items.enumerated() {
|
||||
let provider = item.dragItem.itemProvider
|
||||
|
||||
if provider.canLoadObject(ofClass: CompositionAttachment.self) {
|
||||
let indexPath = IndexPath(row: destinationIndexPath.row + index, section: 0)
|
||||
let placeholder = UITableViewDropPlaceholder(insertionIndexPath: indexPath, reuseIdentifier: "composeAttachment", rowHeight: 96)
|
||||
placeholder.cellUpdateHandler = { (cell) in
|
||||
let cell = cell as! ComposeAttachmentTableViewCell
|
||||
cell.setEnabled(false)
|
||||
}
|
||||
let placeholderContext = coordinator.drop(item.dragItem, to: placeholder)
|
||||
|
||||
provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in
|
||||
DispatchQueue.main.async {
|
||||
if let attachment = object as? CompositionAttachment {
|
||||
placeholderContext.commitInsertion { (insertionIndexPath) in
|
||||
self.attachments.insert(attachment, at: insertionIndexPath.row)
|
||||
}
|
||||
} else {
|
||||
placeholderContext.deletePlaceholder()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateHeightConstraint()
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeAttachmentsViewController: AssetPickerViewControllerDelegate {
|
||||
func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool {
|
||||
switch mastodonController.instance.instanceType {
|
||||
case .pleroma:
|
||||
return true
|
||||
case .mastodon:
|
||||
if (type == .video && attachments.count > 0) ||
|
||||
attachments.contains(where: { $0.data.type == .video }) ||
|
||||
assetPicker.currentCollectionSelectedAssets.contains(where: { $0.type == .video }) {
|
||||
return false
|
||||
}
|
||||
return attachments.count + assetPicker.currentCollectionSelectedAssets.count < 4
|
||||
}
|
||||
}
|
||||
|
||||
func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachmentData]) {
|
||||
let attachments = attachments.map {
|
||||
CompositionAttachment(data: $0)
|
||||
}
|
||||
let indexPaths = attachments.indices.map { IndexPath(row: $0 + self.attachments.count, section: 0) }
|
||||
self.attachments.append(contentsOf: attachments)
|
||||
tableView.insertRows(at: indexPaths, with: .automatic)
|
||||
updateHeightConstraint()
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeAttachmentsViewController: ComposeAttachmentTableViewCellDelegate {
|
||||
func composeAttachment(_ cell: ComposeAttachmentTableViewCell, present viewController: UIViewController, animated: Bool) {
|
||||
self.present(viewController, animated: animated)
|
||||
}
|
||||
|
||||
func removeAttachment(_ cell: ComposeAttachmentTableViewCell) {
|
||||
guard let indexPath = tableView.indexPath(for: cell) else { return }
|
||||
attachments.remove(at: indexPath.row)
|
||||
tableView.performBatchUpdates({
|
||||
tableView.deleteRows(at: [indexPath], with: .automatic)
|
||||
}, completion: { (_) in
|
||||
// when removing cells, we don't trigger the container height update until after the animation has completed
|
||||
// otherwise, during the animation, the height is too short and the last row briefly disappears
|
||||
self.updateHeightConstraint()
|
||||
})
|
||||
}
|
||||
|
||||
func attachmentDescriptionChanged(_ cell: ComposeAttachmentTableViewCell) {
|
||||
delegate?.composeRequiresAttachmentDescriptionsDidChange()
|
||||
}
|
||||
|
||||
func composeAttachmentDescriptionHeightChanged(_ cell: ComposeAttachmentTableViewCell) {
|
||||
tableView.performBatchUpdates(nil) { (_) in
|
||||
self.updateHeightConstraint()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeAttachmentsViewController: ComposeDrawingViewControllerDelegate {
|
||||
func composeDrawingViewControllerClose(_ drawingController: ComposeDrawingViewController) {
|
||||
dismiss(animated: true)
|
||||
currentlyEditedDrawingIndex = nil
|
||||
}
|
||||
|
||||
func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing) {
|
||||
let newAttachment = CompositionAttachment(data: .drawing(drawing))
|
||||
|
||||
if let currentlyEditedDrawingIndex = currentlyEditedDrawingIndex {
|
||||
attachments[currentlyEditedDrawingIndex] = newAttachment
|
||||
tableView.reloadRows(at: [IndexPath(row: currentlyEditedDrawingIndex, section: 0)], with: .automatic)
|
||||
} else {
|
||||
attachments.append(newAttachment)
|
||||
tableView.insertRows(at: [IndexPath(row: self.attachments.count - 1, section: 0)], with: .automatic)
|
||||
updateHeightConstraint()
|
||||
}
|
||||
|
||||
dismiss(animated: true)
|
||||
currentlyEditedDrawingIndex = nil
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
//
|
||||
// ComposeAvatarImageView.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/18/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ComposeAvatarImageView: View {
|
||||
let url: URL
|
||||
@State var request: ImageCache.Request? = nil
|
||||
@State var avatarImage: UIImage? = nil
|
||||
@ObservedObject var preferences = Preferences.shared
|
||||
|
||||
var body: some View {
|
||||
image
|
||||
.resizable()
|
||||
.frame(width: 50, height: 50)
|
||||
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
|
||||
.onAppear(perform: self.loadImage)
|
||||
.onDisappear(perform: self.cancelRequest)
|
||||
}
|
||||
|
||||
private var image: Image {
|
||||
if let avatarImage = avatarImage {
|
||||
return Image(uiImage: avatarImage)
|
||||
} else {
|
||||
let imageName: String
|
||||
switch preferences.avatarStyle {
|
||||
case .circle:
|
||||
imageName = "person.crop.circle"
|
||||
case .roundRect:
|
||||
imageName = "person.crop.square"
|
||||
}
|
||||
return Image(systemName: imageName)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadImage() {
|
||||
request = ImageCache.avatars.get(url) { (data) in
|
||||
DispatchQueue.main.async {
|
||||
self.request = nil
|
||||
if let data = data, let image = UIImage(data: data) {
|
||||
self.avatarImage = image
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelRequest() {
|
||||
request?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
struct ComposeAvatarImageView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ComposeAvatarImageView(url: URL(string: "https://social.shadowfacts.net/media/4b481afc591a8f3d11d0f5732e5cb320422dec72d7f223ebb5f35d5d0e821a9c.png")!)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// ComposeContainerView.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/24/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
struct ComposeContainerView: View {
|
||||
let mastodonController: MastodonController
|
||||
@ObservedObject var uiState: ComposeUIState
|
||||
|
||||
init(
|
||||
mastodonController: MastodonController,
|
||||
uiState: ComposeUIState
|
||||
) {
|
||||
self.mastodonController = mastodonController
|
||||
self.uiState = uiState
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ComposeView(draft: uiState.draft)
|
||||
.environmentObject(mastodonController)
|
||||
.environmentObject(uiState)
|
||||
}
|
||||
}
|
||||
|
||||
//struct ComposeContainerView_Previews: PreviewProvider {
|
||||
// static var previews: some View {
|
||||
// ComposeContainerView()
|
||||
// }
|
||||
//}
|
|
@ -0,0 +1,41 @@
|
|||
//
|
||||
// ComposeCurrentAccount.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/18/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
|
||||
struct ComposeCurrentAccount: View {
|
||||
@EnvironmentObject var mastodonController: MastodonController
|
||||
|
||||
var account: Account {
|
||||
mastodonController.account!
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
ComposeAvatarImageView(url: account.avatar)
|
||||
.accessibility(label: Text("\(account.displayName) avatar"))
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
AccountDisplayNameLabel(account: mastodonController.persistentContainer.account(for: account.id)!, fontSize: 20)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(verbatim: "@\(account.acct)")
|
||||
.font(.system(size: 17, weight: .light))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//struct ComposeCurrentAccount_Previews: PreviewProvider {
|
||||
// static var previews: some View {
|
||||
// ComposeCurrentAccount(account: )
|
||||
// }
|
||||
//}
|
|
@ -58,7 +58,15 @@ class ComposeDrawingViewController: UIViewController {
|
|||
canvasView.drawing = initialDrawing
|
||||
}
|
||||
canvasView.delegate = self
|
||||
#if SDK_IOS_14
|
||||
if #available(iOS 14.0, *) {
|
||||
canvasView.drawingPolicy = .anyInput
|
||||
} else {
|
||||
canvasView.allowsFingerDrawing = true
|
||||
}
|
||||
#else
|
||||
canvasView.allowsFingerDrawing = true
|
||||
#endif
|
||||
canvasView.minimumZoomScale = 0.5
|
||||
canvasView.maximumZoomScale = 2
|
||||
canvasView.backgroundColor = .systemBackground
|
||||
|
@ -75,6 +83,7 @@ class ComposeDrawingViewController: UIViewController {
|
|||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
// todo: should the PKToolPicker be owned by this VC or something else?
|
||||
if let window = parent?.view.window, let toolPicker = PKToolPicker.shared(for: window) {
|
||||
toolPicker.setVisible(true, forFirstResponder: canvasView)
|
||||
toolPicker.addObserver(canvasView)
|
||||
|
@ -166,9 +175,3 @@ extension ComposeDrawingViewController: PKToolPickerObserver {
|
|||
updateLayout(for: toolPicker)
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeDrawingViewController: UIGestureRecognizerDelegate {
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
return otherGestureRecognizer == canvasView.drawingGestureRecognizer
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,408 @@
|
|||
//
|
||||
// ComposeHostingController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/22/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import Pachyderm
|
||||
import PencilKit
|
||||
|
||||
class ComposeHostingController: UIHostingController<ComposeContainerView> {
|
||||
|
||||
let mastodonController: MastodonController
|
||||
|
||||
let uiState: ComposeUIState
|
||||
|
||||
var draft: Draft { uiState.draft }
|
||||
|
||||
private var cancellables = [AnyCancellable]()
|
||||
|
||||
private var keyboardHeight: CGFloat = 0
|
||||
private var toolbarHeight: CGFloat = 44
|
||||
|
||||
private var mainToolbar: UIToolbar!
|
||||
private var inputAccessoryToolbar: UIToolbar!
|
||||
private var visibilityBarButtonItems = [UIBarButtonItem]()
|
||||
|
||||
override var inputAccessoryView: UIView? { inputAccessoryToolbar }
|
||||
|
||||
init(draft: Draft? = nil, mastodonController: MastodonController) {
|
||||
self.mastodonController = mastodonController
|
||||
let realDraft = draft ?? Draft(accountID: mastodonController.accountInfo!.id)
|
||||
DraftsManager.shared.add(realDraft)
|
||||
|
||||
self.uiState = ComposeUIState(draft: realDraft)
|
||||
|
||||
// we need our own environment object wrapper so that we can set the mastodon controller as an
|
||||
// environment object and setup the draft change listener while still having a concrete type
|
||||
// to use as the UIHostingController type parameter
|
||||
let container = ComposeContainerView(
|
||||
mastodonController: mastodonController,
|
||||
uiState: uiState
|
||||
)
|
||||
super.init(rootView: container)
|
||||
|
||||
self.uiState.delegate = self
|
||||
|
||||
// main toolbar is shown at the bottom of the screen, the input accessory is attached to the keyboard while editing
|
||||
mainToolbar = createToolbar()
|
||||
inputAccessoryToolbar = createToolbar()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil)
|
||||
|
||||
// add the height of the toolbar itself to the bottom of the safe area so content inside SwiftUI ScrollView doesn't underflow it
|
||||
updateAdditionalSafeAreaInsets()
|
||||
|
||||
pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self)
|
||||
|
||||
userActivity = UserActivityManager.newPostActivity()
|
||||
|
||||
self.uiState.$draft
|
||||
.flatMap(\.$visibility)
|
||||
.sink(receiveValue: self.visibilityChanged)
|
||||
.store(in: &cancellables)
|
||||
|
||||
self.uiState.$draft
|
||||
.flatMap(\.objectWillChange)
|
||||
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility))
|
||||
.sink {
|
||||
DraftsManager.save()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
// can't do this in viewDidLoad because viewDidLoad isn't called for UIHostingController
|
||||
// if mainToolbar.superview == nil {
|
||||
// view.addSubview(mainToolbar)
|
||||
// NSLayoutConstraint.activate([
|
||||
// mainToolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
// mainToolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
// // use the top anchor of the toolbar so our additionalSafeAreaInsets (which has the bottom as the toolbar height) don't affect it
|
||||
// mainToolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
||||
// ])
|
||||
// }
|
||||
}
|
||||
|
||||
override func didMove(toParent parent: UIViewController?) {
|
||||
super.didMove(toParent: parent)
|
||||
|
||||
if let parent = parent {
|
||||
parent.view.addSubview(mainToolbar)
|
||||
NSLayoutConstraint.activate([
|
||||
mainToolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
mainToolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
// use the top anchor of the toolbar so our additionalSafeAreaInsets (which has the bottom as the toolbar height) don't affect it
|
||||
mainToolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
if !draft.hasContent {
|
||||
DraftsManager.shared.remove(draft)
|
||||
}
|
||||
DraftsManager.save()
|
||||
}
|
||||
|
||||
private func createToolbar() -> UIToolbar {
|
||||
let toolbar = UIToolbar()
|
||||
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
||||
toolbar.isAccessibilityElement = true
|
||||
|
||||
let visibilityAction: Selector?
|
||||
if #available(iOS 14.0, *) {
|
||||
visibilityAction = nil
|
||||
} else {
|
||||
visibilityAction = #selector(visibilityButtonPressed(_:))
|
||||
}
|
||||
let visibilityItem = UIBarButtonItem(image: UIImage(systemName: draft.visibility.imageName), style: .plain, target: self, action: visibilityAction)
|
||||
visibilityBarButtonItems.append(visibilityItem)
|
||||
visibilityChanged(draft.visibility)
|
||||
|
||||
toolbar.items = [
|
||||
UIBarButtonItem(title: "CW", style: .plain, target: self, action: #selector(cwButtonPressed)),
|
||||
visibilityItem,
|
||||
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
|
||||
UIBarButtonItem(title: "Drafts", style: .plain, target: self, action: #selector(draftsButtonPresed))
|
||||
]
|
||||
return toolbar
|
||||
}
|
||||
|
||||
private func updateAdditionalSafeAreaInsets() {
|
||||
additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: toolbarHeight + keyboardHeight, right: 0)
|
||||
}
|
||||
|
||||
@objc private func keyboardWillShow(_ notification: Foundation.Notification) {
|
||||
keyboardWillShow(accessoryView: inputAccessoryToolbar, notification: notification)
|
||||
}
|
||||
|
||||
func keyboardWillShow(accessoryView: UIView, notification: Foundation.Notification) {
|
||||
mainToolbar.isHidden = true
|
||||
|
||||
accessoryView.alpha = 1
|
||||
accessoryView.isHidden = false
|
||||
|
||||
// on iOS 14, SwiftUI safe area automatically includes the keyboard
|
||||
if #available(iOS 14.0, *) {
|
||||
} else {
|
||||
let userInfo = notification.userInfo!
|
||||
let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
|
||||
// temporarily reset add'l safe area insets so we can access the default inset
|
||||
additionalSafeAreaInsets = .zero
|
||||
keyboardHeight = frame.height - view.safeAreaInsets.bottom - accessoryView.frame.height
|
||||
updateAdditionalSafeAreaInsets()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func keyboardWillHide(_ notification: Foundation.Notification) {
|
||||
keyboardWillHide(accessoryView: inputAccessoryToolbar, notification: notification)
|
||||
}
|
||||
|
||||
func keyboardWillHide(accessoryView: UIView, notification: Foundation.Notification) {
|
||||
mainToolbar.isHidden = false
|
||||
|
||||
let userInfo = notification.userInfo!
|
||||
let durationObj = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as! NSNumber
|
||||
let duration = TimeInterval(durationObj.doubleValue)
|
||||
let curveValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as! NSNumber
|
||||
let curve = UIView.AnimationCurve(rawValue: curveValue.intValue)!
|
||||
let curveOption: UIView.AnimationOptions
|
||||
switch curve {
|
||||
case .easeInOut:
|
||||
curveOption = .curveEaseInOut
|
||||
case .easeIn:
|
||||
curveOption = .curveEaseIn
|
||||
case .easeOut:
|
||||
curveOption = .curveEaseOut
|
||||
case .linear:
|
||||
curveOption = .curveLinear
|
||||
@unknown default:
|
||||
curveOption = .curveLinear
|
||||
}
|
||||
UIView.animate(withDuration: duration, delay: 0, options: curveOption) {
|
||||
accessoryView.alpha = 0
|
||||
} completion: { (finished) in
|
||||
accessoryView.alpha = 1
|
||||
}
|
||||
|
||||
// on iOS 14, SwiftUI safe area automatically includes the keyboard
|
||||
if #available(iOS 14.0, *) {
|
||||
} else {
|
||||
keyboardHeight = 0
|
||||
updateAdditionalSafeAreaInsets()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func keyboardDidHide(_ notification: Foundation.Notification) {
|
||||
keyboardDidHide(accessoryView: inputAccessoryToolbar, notification: notification)
|
||||
}
|
||||
|
||||
func keyboardDidHide(accessoryView: UIView, notification: Foundation.Notification) {
|
||||
accessoryView.isHidden = true
|
||||
}
|
||||
|
||||
private func visibilityChanged(_ newVisibility: Status.Visibility) {
|
||||
for item in visibilityBarButtonItems {
|
||||
item.image = UIImage(systemName: newVisibility.imageName)
|
||||
item.image!.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName)
|
||||
item.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName)
|
||||
if #available(iOS 14.0, *) {
|
||||
let elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in
|
||||
let state = visibility == newVisibility ? UIMenuElement.State.on : .off
|
||||
return UIAction(title: visibility.displayName, image: UIImage(systemName: visibility.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: state) { (_) in
|
||||
self.draft.visibility = visibility
|
||||
}
|
||||
}
|
||||
item.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: elements)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
|
||||
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { return false }
|
||||
switch mastodonController.instance.instanceType {
|
||||
case .pleroma:
|
||||
return true
|
||||
case .mastodon:
|
||||
guard draft.attachments.allSatisfy({ $0.data.type == .image }) else { return false }
|
||||
// todo: if providers are videos, this technically allows invalid video/image combinations
|
||||
return itemProviders.count + draft.attachments.count <= 4
|
||||
}
|
||||
}
|
||||
|
||||
override func paste(itemProviders: [NSItemProvider]) {
|
||||
for provider in itemProviders where provider.canLoadObject(ofClass: CompositionAttachment.self) {
|
||||
provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in
|
||||
guard let attachment = object as? CompositionAttachment else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.draft.attachments.append(attachment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
||||
@objc func cwButtonPressed() {
|
||||
draft.contentWarningEnabled = !draft.contentWarningEnabled
|
||||
}
|
||||
|
||||
@objc func visibilityButtonPressed(_ sender: UIBarButtonItem) {
|
||||
// if #available(iOS 14.0, *) {
|
||||
// } else {
|
||||
let alertController = UIAlertController(currentVisibility: draft.visibility) { (visibility) in
|
||||
guard let visibility = visibility else { return }
|
||||
self.draft.visibility = visibility
|
||||
}
|
||||
alertController.popoverPresentationController?.barButtonItem = sender
|
||||
present(alertController, animated: true)
|
||||
// }
|
||||
}
|
||||
|
||||
@objc func draftsButtonPresed() {
|
||||
let draftsVC = DraftsTableViewController(account: mastodonController.accountInfo!, exclude: draft)
|
||||
draftsVC.delegate = self
|
||||
present(UINavigationController(rootViewController: draftsVC), animated: true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ComposeHostingController: ComposeUIStateDelegate {
|
||||
var assetPickerDelegate: AssetPickerViewControllerDelegate? { self }
|
||||
|
||||
func dismissCompose() {
|
||||
self.dismiss(animated: true)
|
||||
}
|
||||
|
||||
func presentAssetPickerSheet() {
|
||||
let sheetContainer = AssetPickerSheetContainerViewController()
|
||||
sheetContainer.assetPicker.assetPickerDelegate = self
|
||||
self.present(sheetContainer, animated: true)
|
||||
}
|
||||
|
||||
func presentComposeDrawing() {
|
||||
let drawing: PKDrawing
|
||||
|
||||
if case let .edit(id) = uiState.composeDrawingMode,
|
||||
let attachment = draft.attachments.first(where: { $0.id == id }),
|
||||
case let .drawing(existingDrawing) = attachment.data {
|
||||
drawing = existingDrawing
|
||||
} else {
|
||||
drawing = PKDrawing()
|
||||
}
|
||||
|
||||
let drawingVC = ComposeDrawingViewController(editing: drawing)
|
||||
drawingVC.delegate = self
|
||||
let nav = UINavigationController(rootViewController: drawingVC)
|
||||
nav.modalPresentationStyle = .fullScreen
|
||||
present(nav, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeHostingController: AssetPickerViewControllerDelegate {
|
||||
func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool {
|
||||
switch mastodonController.instance.instanceType {
|
||||
case .pleroma:
|
||||
return true
|
||||
case .mastodon:
|
||||
if (type == .video && draft.attachments.count > 0) ||
|
||||
draft.attachments.contains(where: { $0.data.type == .video }) ||
|
||||
assetPicker.currentCollectionSelectedAssets.contains(where: { $0.type == .video }) {
|
||||
return false
|
||||
}
|
||||
return draft.attachments.count + assetPicker.currentCollectionSelectedAssets.count < 4
|
||||
}
|
||||
}
|
||||
|
||||
func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachmentData]) {
|
||||
let attachments = attachments.map {
|
||||
CompositionAttachment(data: $0)
|
||||
}
|
||||
draft.attachments.append(contentsOf: attachments)
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeHostingController: DraftsTableViewControllerDelegate {
|
||||
func draftSelectionCanceled() {
|
||||
}
|
||||
|
||||
func shouldSelectDraft(_ draft: Draft, completion: @escaping (Bool) -> Void) {
|
||||
if draft.inReplyToID != self.draft.inReplyToID,
|
||||
self.draft.hasContent {
|
||||
let alertController = UIAlertController(title: "Different Reply", message: "The selected draft is a reply to a different status, do you wish to use it?", preferredStyle: .alert)
|
||||
alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { (_) in
|
||||
completion(false)
|
||||
}))
|
||||
alertController.addAction(UIAlertAction(title: "Restore Draft", style: .default, handler: { (_) in
|
||||
completion(true)
|
||||
}))
|
||||
// we can't present the laert ourselves since the compose VC is already presenting the draft selector
|
||||
// but presenting on the presented view controller seems hacky, is there a better way to do this?
|
||||
presentedViewController!.present(alertController, animated: true)
|
||||
} else {
|
||||
completion(true)
|
||||
}
|
||||
}
|
||||
|
||||
func draftSelected(_ draft: Draft) {
|
||||
if self.draft.hasContent {
|
||||
DraftsManager.save()
|
||||
} else {
|
||||
DraftsManager.shared.remove(self.draft)
|
||||
}
|
||||
|
||||
uiState.draft = draft
|
||||
}
|
||||
|
||||
func draftSelectionCompleted() {
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeHostingController: UIAdaptivePresentationControllerDelegate {
|
||||
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
|
||||
return Preferences.shared.automaticallySaveDrafts || !draft.hasContent
|
||||
}
|
||||
|
||||
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
|
||||
uiState.isShowingSaveDraftSheet = true
|
||||
}
|
||||
|
||||
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
||||
DraftsManager.save()
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeHostingController: ComposeDrawingViewControllerDelegate {
|
||||
func composeDrawingViewControllerClose(_ drawingController: ComposeDrawingViewController) {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing) {
|
||||
switch uiState.composeDrawingMode {
|
||||
case nil, .createNew:
|
||||
let attachment = CompositionAttachment(data: .drawing(drawing))
|
||||
draft.attachments.append(attachment)
|
||||
|
||||
case let .edit(id):
|
||||
let existing = draft.attachments.first { $0.id == id }
|
||||
existing?.data = .drawing(drawing)
|
||||
}
|
||||
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
//
|
||||
// ComposeHostingController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/18/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
class ComposeHostingController: UIHostingController<ComposeView> {
|
||||
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
//
|
||||
// ComposeReplyContentView.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/22/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ComposeReplyContentView: UIViewRepresentable {
|
||||
typealias UIViewType = ComposeReplyContentTextView
|
||||
|
||||
let status: StatusMO
|
||||
|
||||
@EnvironmentObject var mastodonController: MastodonController
|
||||
|
||||
let heightChanged: (CGFloat) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> ComposeReplyContentTextView {
|
||||
let view = ComposeReplyContentTextView()
|
||||
view.overrideMastodonController = mastodonController
|
||||
view.setTextFrom(status: status)
|
||||
view.isUserInteractionEnabled = false
|
||||
view.backgroundColor = .clear
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: ComposeReplyContentTextView, context: Context) {
|
||||
uiView.heightChanged = heightChanged
|
||||
}
|
||||
}
|
||||
|
||||
class ComposeReplyContentTextView: StatusContentTextView {
|
||||
var heightChanged: ((CGFloat) -> Void)?
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
heightChanged?(contentSize.height)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
//
|
||||
// ComposeReplyView.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/22/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ComposeReplyView: View {
|
||||
let status: StatusMO
|
||||
let stackPadding: CGFloat
|
||||
let outerMinY: CGFloat
|
||||
|
||||
@State private var contentHeight: CGFloat?
|
||||
|
||||
private let horizSpacing: CGFloat = 8
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: horizSpacing) {
|
||||
GeometryReader(content: self.replyAvatarImage)
|
||||
.frame(width: 50)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
AccountDisplayNameLabel(account: status.account, fontSize: 17)
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
|
||||
Text(verbatim: "@\(status.account.acct)")
|
||||
.font(.system(size: 17, weight: .light))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
ComposeReplyContentView(status: status) { (newHeight) in
|
||||
self.contentHeight = newHeight
|
||||
}
|
||||
.frame(height: contentHeight)
|
||||
.offset(x: -4, y: -8)
|
||||
.padding(.bottom, -8)
|
||||
}
|
||||
.frame(minHeight: 50 + 8)
|
||||
}
|
||||
.padding(.bottom, -8)
|
||||
}
|
||||
|
||||
private func replyAvatarImage(geometry: GeometryProxy) -> some View {
|
||||
// using named coordinate spaces produces an incorrect scroll offset on iOS 13,
|
||||
// so simply compare the geometry inside and outside the scroll view in the global coordinate space
|
||||
var scrollOffset = outerMinY - geometry.frame(in: .global).minY
|
||||
scrollOffset += stackPadding
|
||||
let offset = min(max(scrollOffset, 0), geometry.size.height - 50 - stackPadding)
|
||||
return ComposeAvatarImageView(url: status.account.avatar)
|
||||
.offset(x: 0, y: offset)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//struct ComposeReplyView_Previews: PreviewProvider {
|
||||
// static var previews: some View {
|
||||
// ComposeReplyView()
|
||||
// }
|
||||
//}
|
|
@ -0,0 +1,125 @@
|
|||
//
|
||||
// ComposeTextView.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/18/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ComposeTextView: View {
|
||||
@Binding private var text: String
|
||||
private let placeholder: Text?
|
||||
private let minHeight: CGFloat
|
||||
|
||||
private var heightDidChange: ((CGFloat) -> Void)?
|
||||
private var backgroundColor = UIColor.secondarySystemBackground
|
||||
private var fontSize: CGFloat = 20
|
||||
|
||||
@State private var height: CGFloat?
|
||||
|
||||
init(text: Binding<String>, placeholder: Text?, minHeight: CGFloat = 150) {
|
||||
self._text = text
|
||||
self.placeholder = placeholder
|
||||
self.minHeight = minHeight
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
WrappedTextView(
|
||||
text: $text,
|
||||
textDidChange: self.textDidChange,
|
||||
backgroundColor: backgroundColor,
|
||||
font: .systemFont(ofSize: fontSize)
|
||||
)
|
||||
.frame(height: height ?? minHeight)
|
||||
|
||||
if text.isEmpty, let placeholder = placeholder {
|
||||
placeholder
|
||||
.font(.system(size: fontSize))
|
||||
.foregroundColor(.secondary)
|
||||
.offset(x: 4, y: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func textDidChange(textView: UITextView) {
|
||||
height = max(minHeight, textView.contentSize.height)
|
||||
heightDidChange?(height!)
|
||||
}
|
||||
|
||||
func heightDidChange(_ callback: @escaping (CGFloat) -> Void) -> Self {
|
||||
var copy = self
|
||||
copy.heightDidChange = callback
|
||||
return copy
|
||||
}
|
||||
|
||||
func backgroundColor(_ color: UIColor) -> Self {
|
||||
var copy = self
|
||||
copy.backgroundColor = color
|
||||
return copy
|
||||
}
|
||||
|
||||
func fontSize(_ size: CGFloat) -> Self {
|
||||
var copy = self
|
||||
copy.fontSize = size
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
||||
struct WrappedTextView: UIViewRepresentable {
|
||||
typealias UIViewType = UITextView
|
||||
|
||||
@Binding var text: String
|
||||
var textDidChange: ((UITextView) -> Void)?
|
||||
var backgroundColor = UIColor.secondarySystemBackground
|
||||
var font = UIFont.systemFont(ofSize: 20)
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let textView = UITextView()
|
||||
textView.delegate = context.coordinator
|
||||
textView.isEditable = true
|
||||
textView.backgroundColor = backgroundColor
|
||||
textView.font = font
|
||||
textView.textContainer.lineBreakMode = .byWordWrapping
|
||||
return textView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UITextView, context: Context) {
|
||||
uiView.text = text
|
||||
context.coordinator.text = $text
|
||||
context.coordinator.didChange = textDidChange
|
||||
// wait until the next runloop iteration so that SwiftUI view updates have finished and
|
||||
// the text view knows its new content size
|
||||
DispatchQueue.main.async {
|
||||
self.textDidChange?(uiView)
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
return Coordinator(text: $text, didChange: textDidChange)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UITextViewDelegate {
|
||||
var text: Binding<String>
|
||||
var didChange: ((UITextView) -> Void)?
|
||||
|
||||
init(text: Binding<String>, didChange: ((UITextView) -> Void)?) {
|
||||
self.text = text
|
||||
self.didChange = didChange
|
||||
}
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
text.wrappedValue = textView.text
|
||||
didChange?(textView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//struct ComposeTextView_Previews: PreviewProvider {
|
||||
// static var previews: some View {
|
||||
// ComposeTextView()
|
||||
// }
|
||||
//}
|
|
@ -0,0 +1,44 @@
|
|||
//
|
||||
// ComposeUIState.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/24/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
protocol ComposeUIStateDelegate: class {
|
||||
var assetPickerDelegate: AssetPickerViewControllerDelegate? { get }
|
||||
|
||||
func dismissCompose()
|
||||
func presentAssetPickerSheet()
|
||||
func presentComposeDrawing()
|
||||
|
||||
func keyboardWillShow(accessoryView: UIView, notification: Notification)
|
||||
func keyboardWillHide(accessoryView: UIView, notification: Notification)
|
||||
func keyboardDidHide(accessoryView: UIView, notification: Notification)
|
||||
}
|
||||
|
||||
class ComposeUIState: ObservableObject {
|
||||
|
||||
weak var delegate: ComposeUIStateDelegate?
|
||||
|
||||
@Published var draft: Draft
|
||||
@Published var isShowingSaveDraftSheet = false
|
||||
@Published var attachmentsMissingDescriptions = Set<UUID>()
|
||||
|
||||
var composeDrawingMode: ComposeDrawingMode?
|
||||
|
||||
init(draft: Draft) {
|
||||
self.draft = draft
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ComposeUIState {
|
||||
enum ComposeDrawingMode {
|
||||
case createNew
|
||||
case edit(id: UUID)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,341 @@
|
|||
//
|
||||
// ComposeView.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/18/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import Combine
|
||||
|
||||
struct ComposeView: View {
|
||||
@ObservedObject var draft: Draft
|
||||
@EnvironmentObject var mastodonController: MastodonController
|
||||
@EnvironmentObject var uiState: ComposeUIState
|
||||
|
||||
@State private var isPosting = false
|
||||
@State private var postProgress: Double = 0
|
||||
@State private var postTotalProgress: Double = 0
|
||||
@State private var isShowingPostErrorAlert = false
|
||||
@State private var postError: PostError?
|
||||
|
||||
private let stackPadding: CGFloat = 8
|
||||
|
||||
init(draft: Draft) {
|
||||
self.draft = draft
|
||||
}
|
||||
|
||||
var charactersRemaining: Int {
|
||||
let limit = mastodonController.instance.maxStatusCharacters ?? 500
|
||||
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
|
||||
return limit - (cwCount + CharacterCounter.count(text: draft.text))
|
||||
}
|
||||
|
||||
var requiresAttachmentDescriptions: Bool {
|
||||
guard Preferences.shared.requireAttachmentDescriptions else { return false }
|
||||
let attachmentIds = draft.attachments.map(\.id)
|
||||
return attachmentIds.contains { uiState.attachmentsMissingDescriptions.contains($0) }
|
||||
}
|
||||
|
||||
var postButtonEnabled: Bool {
|
||||
draft.hasContent && charactersRemaining >= 0 && !isPosting && !requiresAttachmentDescriptions
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// the pre-iOS 14 API does not result in the correct pointer interactions for nav bar buttons, see FB8595468
|
||||
if #available(iOS 14.0, *) {
|
||||
mostOfTheBody.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { cancelButton }
|
||||
ToolbarItem(placement: .confirmationAction) { postButton }
|
||||
}
|
||||
} else {
|
||||
mostOfTheBody.navigationBarItems(leading: cancelButton, trailing: postButton)
|
||||
}
|
||||
}
|
||||
|
||||
var mostOfTheBody: some View {
|
||||
ZStack(alignment: .top) {
|
||||
GeometryReader { (outer) in
|
||||
ScrollView(.vertical) {
|
||||
mainStack(outerMinY: outer.frame(in: .global).minY)
|
||||
}
|
||||
}
|
||||
|
||||
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
|
||||
WrappedProgressView(value: postProgress, total: postTotalProgress)
|
||||
}
|
||||
.onAppear(perform: self.didAppear)
|
||||
.navigationBarTitle("Compose")
|
||||
.actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet)
|
||||
.alert(isPresented: $isShowingPostErrorAlert) {
|
||||
Alert(
|
||||
title: Text("Error Posting Status"),
|
||||
message: Text(postError?.localizedDescription ?? ""),
|
||||
dismissButton: .default(Text("OK"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func mainStack(outerMinY: CGFloat) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let id = draft.inReplyToID,
|
||||
let status = mastodonController.persistentContainer.status(for: id) {
|
||||
ComposeReplyView(
|
||||
status: status,
|
||||
stackPadding: stackPadding,
|
||||
outerMinY: outerMinY
|
||||
)
|
||||
}
|
||||
|
||||
header
|
||||
|
||||
if draft.contentWarningEnabled {
|
||||
TextField("Write your warning here", text: $draft.contentWarning)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
}
|
||||
|
||||
MainComposeTextView(
|
||||
draft: draft,
|
||||
placeholder: Text("What's on your mind?")
|
||||
)
|
||||
|
||||
ComposeAttachmentsList(
|
||||
draft: draft
|
||||
)
|
||||
// the list rows provide their own padding, so we cancel out the extra spacing from the VStack
|
||||
.padding([.top, .bottom], -8)
|
||||
}
|
||||
.padding(stackPadding)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top) {
|
||||
ComposeCurrentAccount()
|
||||
Spacer()
|
||||
Text(verbatim: charactersRemaining.description)
|
||||
.foregroundColor(charactersRemaining < 0 ? .red : .secondary)
|
||||
.font(Font.body.monospacedDigit())
|
||||
.accessibility(label: Text(charactersRemaining < 0 ? "\(-charactersRemaining) characters too many" : "\(charactersRemaining) characters remaining"))
|
||||
}.frame(height: 50)
|
||||
}
|
||||
|
||||
private var cancelButton: some View {
|
||||
Button(action: self.cancel) {
|
||||
Text("Cancel")
|
||||
// otherwise all Buttons in the nav bar are made semibold
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
}
|
||||
}
|
||||
|
||||
private var postButton: some View {
|
||||
Button(action: self.postStatus) {
|
||||
Text("Post")
|
||||
}
|
||||
.disabled(!postButtonEnabled)
|
||||
}
|
||||
|
||||
private func didAppear() {
|
||||
let proxy = UIScrollView.appearance(whenContainedInInstancesOf: [ComposeHostingController.self])
|
||||
proxy.keyboardDismissMode = .interactive
|
||||
}
|
||||
|
||||
private func cancel() {
|
||||
if Preferences.shared.automaticallySaveDrafts {
|
||||
// draft is already stored in drafts manager, drafts manager is saved by ComposeHostingController.viewWillDisappear
|
||||
uiState.delegate?.dismissCompose()
|
||||
} else {
|
||||
// if the draft doesn't have content, it doesn't need to be saved
|
||||
if draft.hasContent {
|
||||
uiState.isShowingSaveDraftSheet = true
|
||||
} else {
|
||||
DraftsManager.shared.remove(draft)
|
||||
uiState.delegate?.dismissCompose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAndCloseSheet() -> ActionSheet {
|
||||
ActionSheet(title: Text("Do you want to save the current post as a draft?"), buttons: [
|
||||
.default(Text("Save Draft"), action: {
|
||||
// draft is already stored in drafts manager, drafts manager is saved by ComposeHostingController.viewWillDisappear
|
||||
uiState.isShowingSaveDraftSheet = false
|
||||
uiState.delegate?.dismissCompose()
|
||||
}),
|
||||
.destructive(Text("Delete Draft"), action: {
|
||||
DraftsManager.shared.remove(draft)
|
||||
uiState.isShowingSaveDraftSheet = false
|
||||
uiState.delegate?.dismissCompose()
|
||||
}),
|
||||
.cancel(),
|
||||
])
|
||||
}
|
||||
|
||||
private func postStatus() {
|
||||
guard draft.hasContent else { return }
|
||||
|
||||
isPosting = true
|
||||
|
||||
// save before posting, so if a crash occurs during network request, the status won't be lost
|
||||
DraftsManager.save()
|
||||
|
||||
let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : nil
|
||||
let sensitive = contentWarning != nil
|
||||
|
||||
// 2 steps (request data, then upload) for each attachment
|
||||
postTotalProgress = Double(2 + (draft.attachments.count * 2))
|
||||
postProgress = 1
|
||||
|
||||
uploadAttachments { (result) in
|
||||
switch result {
|
||||
case let .failure(error):
|
||||
self.isShowingPostErrorAlert = true
|
||||
self.postError = error
|
||||
self.postProgress = 0
|
||||
self.postTotalProgress = 0
|
||||
self.isPosting = false
|
||||
|
||||
case let .success(uploadedAttachments):
|
||||
let request = Client.createStatus(text: draft.text,
|
||||
contentType: Preferences.shared.statusContentType,
|
||||
inReplyTo: draft.inReplyToID,
|
||||
media: uploadedAttachments,
|
||||
sensitive: sensitive,
|
||||
spoilerText: contentWarning,
|
||||
visibility: draft.visibility,
|
||||
language: nil)
|
||||
self.mastodonController.run(request) { (response) in
|
||||
switch response {
|
||||
case let .failure(error):
|
||||
self.isShowingPostErrorAlert = true
|
||||
self.postError = error
|
||||
|
||||
case .success(_, _):
|
||||
self.postProgress += 1
|
||||
|
||||
DraftsManager.shared.remove(self.draft)
|
||||
|
||||
// wait .25 seconds so the user can see the progress bar has completed
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) {
|
||||
self.uiState.delegate?.dismissCompose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadAttachments(_ completion: @escaping (Result<[Attachment], AttachmentUploadError>) -> Void) {
|
||||
let group = DispatchGroup()
|
||||
|
||||
var attachmentDatas = [(Data, String)?]()
|
||||
|
||||
for (index, compAttachment) in draft.attachments.enumerated() {
|
||||
group.enter()
|
||||
|
||||
attachmentDatas.append(nil)
|
||||
|
||||
compAttachment.data.getData { (data, mimeType) in
|
||||
postProgress += 1
|
||||
|
||||
attachmentDatas[index] = (data, mimeType)
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: .global(qos: .userInitiated)) {
|
||||
|
||||
var anyFailed = false
|
||||
var uploadedAttachments = [Result<Attachment, Error>?]()
|
||||
|
||||
// Mastodon does not respect the order of the `media_ids` parameter in the create post request,
|
||||
// it determines attachment order by which was uploaded first. Since the upload attachment request
|
||||
// does not include any timestamp data, and requests may arrive at the server out-of-order,
|
||||
// attachments need to be uploaded serially in order to ensure the order of attachments in the
|
||||
// posted status reflects order the user set.
|
||||
// Pleroma does respect the order of the `media_ids` parameter.
|
||||
|
||||
for (index, (data, mimeType)) in attachmentDatas.map(\.unsafelyUnwrapped).enumerated() {
|
||||
group.enter()
|
||||
|
||||
let compAttachment = draft.attachments[index]
|
||||
let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: "file")
|
||||
let request = Client.upload(attachment: formAttachment, description: compAttachment.attachmentDescription)
|
||||
self.mastodonController.run(request) { (response) in
|
||||
switch response {
|
||||
case let .failure(error):
|
||||
uploadedAttachments.append(.failure(error))
|
||||
anyFailed = true
|
||||
|
||||
case let .success(attachment, _):
|
||||
self.postProgress += 1
|
||||
uploadedAttachments.append(.success(attachment))
|
||||
}
|
||||
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.wait()
|
||||
}
|
||||
|
||||
|
||||
if anyFailed {
|
||||
let errors = uploadedAttachments.map { (result) -> Error? in
|
||||
if case let .failure(error) = result {
|
||||
return error
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
completion(.failure(AttachmentUploadError(errors: errors)))
|
||||
} else {
|
||||
let uploadedAttachments = uploadedAttachments.map {
|
||||
try! $0!.get()
|
||||
}
|
||||
completion(.success(uploadedAttachments))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate protocol PostError: LocalizedError {}
|
||||
|
||||
extension PostError {
|
||||
var localizedDescription: String {
|
||||
if let self = self as? Client.Error {
|
||||
return self.localizedDescription
|
||||
} else if let self = self as? AttachmentUploadError {
|
||||
return self.localizedDescription
|
||||
} else {
|
||||
return "Unknown Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Client.Error: PostError {}
|
||||
|
||||
fileprivate struct AttachmentUploadError: PostError {
|
||||
let errors: [Error?]
|
||||
|
||||
var localizedDescription: String {
|
||||
return errors.enumerated().compactMap { (index, error) -> String? in
|
||||
guard let error = error else { return nil }
|
||||
let description: String
|
||||
// need to downcast to use more specific localizedDescription impl from Pachyderm
|
||||
if let error = error as? Client.Error {
|
||||
description = error.localizedDescription
|
||||
} else {
|
||||
description = error.localizedDescription
|
||||
}
|
||||
return "Attachment \(index + 1): \(description)"
|
||||
}.joined(separator: ",\n")
|
||||
}
|
||||
}
|
||||
|
||||
//struct ComposeView_Previews: PreviewProvider {
|
||||
// static var previews: some View {
|
||||
// ComposeView()
|
||||
// }
|
||||
//}
|
|
@ -1,618 +0,0 @@
|
|||
//
|
||||
// ComposeViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/28/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import Intents
|
||||
|
||||
class ComposeViewController: UIViewController {
|
||||
|
||||
weak var mastodonController: MastodonController!
|
||||
|
||||
var inReplyToID: String?
|
||||
var accountsToMention = [String]()
|
||||
var initialText: String?
|
||||
var contentWarningEnabled = false {
|
||||
didSet {
|
||||
contentWarningStateChanged()
|
||||
}
|
||||
}
|
||||
var visibility: Status.Visibility! {
|
||||
didSet {
|
||||
visibilityChanged()
|
||||
}
|
||||
}
|
||||
|
||||
var hasChanges = false
|
||||
var currentDraft: DraftsManager.Draft?
|
||||
// Weak so that if a new session is initiated (i.e. XCBManager.currentSession is changed) while the current one is in progress, this one will be released
|
||||
weak var xcbSession: XCBSession?
|
||||
var postedStatus: Status?
|
||||
|
||||
var compositionState: CompositionState = .valid {
|
||||
didSet {
|
||||
postBarButtonItem.isEnabled = compositionState.isValid
|
||||
}
|
||||
}
|
||||
|
||||
weak var postBarButtonItem: UIBarButtonItem!
|
||||
var visibilityBarButtonItem: UIBarButtonItem!
|
||||
var contentWarningBarButtonItem: UIBarButtonItem!
|
||||
|
||||
@IBOutlet weak var scrollView: UIScrollView!
|
||||
@IBOutlet weak var contentView: UIView!
|
||||
@IBOutlet weak var stackView: UIStackView!
|
||||
|
||||
var replyView: ComposeStatusReplyView?
|
||||
var replyAvatarImageViewTopConstraint: NSLayoutConstraint?
|
||||
|
||||
@IBOutlet weak var selfDetailView: LargeAccountDetailView!
|
||||
|
||||
@IBOutlet weak var charactersRemainingLabel: UILabel!
|
||||
@IBOutlet weak var statusTextView: UITextView!
|
||||
@IBOutlet weak var placeholderLabel: UILabel!
|
||||
|
||||
@IBOutlet weak var inReplyToContainer: UIView!
|
||||
@IBOutlet weak var inReplyToLabel: UILabel!
|
||||
@IBOutlet weak var contentWarningContainerView: UIView!
|
||||
@IBOutlet weak var contentWarningTextField: UITextField!
|
||||
|
||||
@IBOutlet weak var composeAttachmentsContainerView: UIView!
|
||||
|
||||
@IBOutlet weak var postProgressView: SteppedProgressView!
|
||||
|
||||
var composeAttachmentsViewController: ComposeAttachmentsViewController!
|
||||
|
||||
init(inReplyTo inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil, mastodonController: MastodonController) {
|
||||
self.mastodonController = mastodonController
|
||||
|
||||
self.inReplyToID = inReplyToID
|
||||
if let inReplyToID = inReplyToID, let inReplyTo = mastodonController.persistentContainer.status(for: inReplyToID) {
|
||||
accountsToMention = [inReplyTo.account.acct] + inReplyTo.mentions.map { $0.acct }
|
||||
} else {
|
||||
accountsToMention = []
|
||||
}
|
||||
if let mentioningAcct = mentioningAcct {
|
||||
accountsToMention.append(mentioningAcct)
|
||||
}
|
||||
if let ownAccount = mastodonController.account {
|
||||
accountsToMention.removeAll(where: { acct in ownAccount.acct == acct })
|
||||
}
|
||||
accountsToMention = accountsToMention.uniques()
|
||||
|
||||
super.init(nibName: "ComposeViewController", bundle: nil)
|
||||
|
||||
title = "Compose"
|
||||
tabBarItem.image = UIImage(systemName: "pencil")
|
||||
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(showSaveAndClosePrompt))
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Post", style: .done, target: self, action: #selector(postButtonPressed))
|
||||
postBarButtonItem = navigationItem.rightBarButtonItem
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
scrollView.delegate = self
|
||||
|
||||
statusTextView.delegate = self
|
||||
statusTextView.becomeFirstResponder()
|
||||
|
||||
let toolbar = UIToolbar()
|
||||
contentWarningBarButtonItem = UIBarButtonItem(title: "CW", style: .plain, target: self, action: #selector(contentWarningButtonPressed))
|
||||
contentWarningBarButtonItem.accessibilityLabel = NSLocalizedString("Add Content Warning", comment: "add CW accessibility label")
|
||||
visibilityBarButtonItem = UIBarButtonItem(image: UIImage(systemName: Preferences.shared.defaultPostVisibility.imageName), style: .plain, target: self, action: #selector(visibilityButtonPressed))
|
||||
visibilityBarButtonItem.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), Preferences.shared.defaultPostVisibility.displayName)
|
||||
toolbar.items = [
|
||||
contentWarningBarButtonItem,
|
||||
visibilityBarButtonItem,
|
||||
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
|
||||
] + createFormattingButtons() + [
|
||||
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
|
||||
UIBarButtonItem(title: "Drafts", style: .plain, target: self, action: #selector(draftsButtonPressed))
|
||||
]
|
||||
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
||||
statusTextView.inputAccessoryView = toolbar
|
||||
contentWarningTextField.inputAccessoryView = toolbar
|
||||
|
||||
statusTextView.text = accountsToMention.map({ acct in "@\(acct) " }).joined()
|
||||
initialText = statusTextView.text
|
||||
|
||||
mastodonController.getOwnAccount { (account) in
|
||||
DispatchQueue.main.async {
|
||||
self.selfDetailView.update(account: account)
|
||||
}
|
||||
}
|
||||
|
||||
updateInReplyTo()
|
||||
|
||||
// we have to set the font here, because the monospaced digit font is not available in IB
|
||||
charactersRemainingLabel.font = .monospacedDigitSystemFont(ofSize: 17, weight: .regular)
|
||||
updatePlaceholder()
|
||||
// if the compose screen is opened via the home screen shortcut and app isn't running,
|
||||
// the msatodon instance may not have been loaded yet
|
||||
mastodonController.getOwnInstance { (_) in
|
||||
DispatchQueue.main.async {
|
||||
self.updateCharactersRemaining()
|
||||
}
|
||||
}
|
||||
|
||||
composeAttachmentsViewController = ComposeAttachmentsViewController(attachments: currentDraft?.attachments ?? [], mastodonController: mastodonController)
|
||||
composeRequiresAttachmentDescriptionsDidChange()
|
||||
composeAttachmentsViewController.delegate = self
|
||||
composeAttachmentsViewController.tableView.isScrollEnabled = false
|
||||
composeAttachmentsViewController.tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
embedChild(composeAttachmentsViewController, in: composeAttachmentsContainerView)
|
||||
|
||||
pasteConfiguration = composeAttachmentsViewController.pasteConfiguration
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(contentWarningTextFieldDidChange), name: UITextField.textDidChangeNotification, object: contentWarningTextField)
|
||||
}
|
||||
|
||||
func updateInReplyTo() {
|
||||
if let replyView = replyView {
|
||||
replyView.removeFromSuperview()
|
||||
}
|
||||
|
||||
if let inReplyToID = inReplyToID {
|
||||
if let status = mastodonController.persistentContainer.status(for: inReplyToID) {
|
||||
updateInReplyTo(inReplyTo: status)
|
||||
} else {
|
||||
let loadingVC = LoadingViewController()
|
||||
embedChild(loadingVC)
|
||||
|
||||
let request = Client.getStatus(id: inReplyToID)
|
||||
mastodonController.run(request) { (response) in
|
||||
guard case let .success(status, _) = response else { return }
|
||||
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: true) { (status) in
|
||||
DispatchQueue.main.async {
|
||||
self.updateInReplyTo(inReplyTo: status)
|
||||
loadingVC.removeViewAndController()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
visibility = Preferences.shared.defaultPostVisibility
|
||||
contentWarningEnabled = false
|
||||
|
||||
inReplyToContainer.isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
func updateInReplyTo(inReplyTo: StatusMO) {
|
||||
visibility = inReplyTo.visibility
|
||||
if Preferences.shared.contentWarningCopyMode == .doNotCopy {
|
||||
contentWarningEnabled = false
|
||||
contentWarningContainerView.isHidden = true
|
||||
} else {
|
||||
contentWarningEnabled = !inReplyTo.spoilerText.isEmpty
|
||||
contentWarningContainerView.isHidden = !contentWarningEnabled
|
||||
if Preferences.shared.contentWarningCopyMode == .prependRe,
|
||||
!inReplyTo.spoilerText.lowercased().starts(with: "re:") {
|
||||
contentWarningTextField.text = "re: \(inReplyTo.spoilerText)"
|
||||
} else {
|
||||
contentWarningTextField.text = inReplyTo.spoilerText
|
||||
}
|
||||
}
|
||||
|
||||
let replyView = ComposeStatusReplyView.create()
|
||||
replyView.mastodonController = mastodonController
|
||||
replyView.updateUI(for: inReplyTo)
|
||||
stackView.insertArrangedSubview(replyView, at: 0)
|
||||
|
||||
self.replyView = replyView
|
||||
|
||||
replyAvatarImageViewTopConstraint = replyView.avatarImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8)
|
||||
replyAvatarImageViewTopConstraint!.isActive = true
|
||||
|
||||
inReplyToContainer.isHidden = false
|
||||
// todo: update to use managed objects
|
||||
inReplyToLabel.text = "In reply to \(inReplyTo.account.displayName)"
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
|
||||
}
|
||||
|
||||
func createFormattingButtons() -> [UIBarButtonItem] {
|
||||
guard Preferences.shared.statusContentType != .plain else {
|
||||
return []
|
||||
}
|
||||
|
||||
var formatButtons = StatusFormat.allCases.map { (format) -> UIBarButtonItem in
|
||||
let item: UIBarButtonItem
|
||||
if let image = format.image {
|
||||
item = UIBarButtonItem(image: image, style: .plain, target: self, action: #selector(formatButtonPressed(_:)))
|
||||
} else if let (str, attributes) = format.title {
|
||||
item = UIBarButtonItem(title: str, style: .plain, target: self, action: #selector(formatButtonPressed(_:)))
|
||||
item.setTitleTextAttributes(attributes, for: .normal)
|
||||
item.setTitleTextAttributes(attributes, for: .highlighted)
|
||||
} else {
|
||||
fatalError("StatusFormat must have either an image or a title")
|
||||
}
|
||||
item.tag = StatusFormat.allCases.firstIndex(of: format)!
|
||||
item.accessibilityLabel = format.accessibilityLabel
|
||||
return item
|
||||
}
|
||||
|
||||
for i in (1..<StatusFormat.allCases.count).reversed() {
|
||||
let spacer = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil)
|
||||
spacer.width = 8
|
||||
formatButtons.insert(spacer, at: i)
|
||||
}
|
||||
|
||||
return formatButtons
|
||||
}
|
||||
|
||||
@objc func adjustForKeyboard(notification: NSNotification) {
|
||||
let userInfo = notification.userInfo!
|
||||
|
||||
let keyboardScreenEndFrame = (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! NSValue).cgRectValue
|
||||
let keyboardViewEndFrame = view.convert(keyboardScreenEndFrame, from: view.window)
|
||||
|
||||
if notification.name == UIResponder.keyboardWillHideNotification {
|
||||
scrollView.contentInset = .zero
|
||||
} else {
|
||||
// let accessoryFrame = view.convert(statusTextView.inputAccessoryView!.frame, from: view.window)
|
||||
let offset = keyboardViewEndFrame.height// + accessoryFrame.height
|
||||
// TODO: radar for incorrect keyboard end frame height (either converted or screen)
|
||||
// the value returned is somewhere between the height of the keyboard and the height of the keyboard + accessory
|
||||
// actually maybe not??
|
||||
scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: offset, right: 0)
|
||||
}
|
||||
scrollView.scrollIndicatorInsets = scrollView.contentInset
|
||||
}
|
||||
|
||||
func updateCharactersRemaining() {
|
||||
let count = CharacterCounter.count(text: statusTextView.text)
|
||||
let cwCount = contentWarningEnabled ? (contentWarningTextField.text?.count ?? 0) : 0
|
||||
let remaining = (mastodonController.instance?.maxStatusCharacters ?? 500) - count - cwCount
|
||||
if remaining < 0 {
|
||||
charactersRemainingLabel.textColor = .red
|
||||
compositionState.formUnion(.tooManyCharacters)
|
||||
} else {
|
||||
charactersRemainingLabel.textColor = .darkGray
|
||||
compositionState.subtract(.tooManyCharacters)
|
||||
}
|
||||
charactersRemainingLabel.text = String(remaining)
|
||||
charactersRemainingLabel.accessibilityLabel = String(format: NSLocalizedString("%d characters remaining", comment: "compose characters remaining accessibility label"), remaining)
|
||||
}
|
||||
|
||||
func updateHasChanges() {
|
||||
if let currentDraft = currentDraft {
|
||||
let cw = contentWarningEnabled ? contentWarningTextField.text : nil
|
||||
hasChanges = statusTextView.text != currentDraft.text || cw != currentDraft.contentWarning
|
||||
} else {
|
||||
hasChanges = !statusTextView.text.isEmpty || (contentWarningEnabled && !(contentWarningTextField.text?.isEmpty ?? true))
|
||||
}
|
||||
}
|
||||
|
||||
func updatePlaceholder() {
|
||||
placeholderLabel.isHidden = !statusTextView.text.isEmpty
|
||||
}
|
||||
|
||||
func contentWarningStateChanged() {
|
||||
contentWarningContainerView.isHidden = !contentWarningEnabled
|
||||
if contentWarningEnabled {
|
||||
contentWarningBarButtonItem.accessibilityLabel = NSLocalizedString("Remove Content Warning", comment: "remove CW accessibility label")
|
||||
} else {
|
||||
contentWarningBarButtonItem.accessibilityLabel = NSLocalizedString("Add Content Warning", comment: "add CW accessibility label")
|
||||
}
|
||||
}
|
||||
|
||||
func visibilityChanged() {
|
||||
visibilityBarButtonItem.image = UIImage(systemName: visibility.imageName)
|
||||
visibilityBarButtonItem.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), Preferences.shared.defaultPostVisibility.displayName)
|
||||
}
|
||||
|
||||
func saveDraft() {
|
||||
let attachments = composeAttachmentsViewController.attachments
|
||||
let statusText = statusTextView.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let cw = contentWarningEnabled ? contentWarningTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) : nil
|
||||
let account = mastodonController.accountInfo!
|
||||
if attachments.count == 0, statusText.isEmpty, cw?.isEmpty ?? true {
|
||||
if let currentDraft = self.currentDraft {
|
||||
DraftsManager.shared.remove(currentDraft)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if let currentDraft = self.currentDraft {
|
||||
currentDraft.update(accountID: account.id, text: statusText, contentWarning: cw, attachments: attachments)
|
||||
} else {
|
||||
self.currentDraft = DraftsManager.shared.create(accountID: account.id, text: statusText, contentWarning: cw, inReplyToID: inReplyToID, attachments: attachments)
|
||||
}
|
||||
}
|
||||
DraftsManager.save()
|
||||
}
|
||||
|
||||
@objc func close() {
|
||||
dismiss(animated: true)
|
||||
xcbSession?.complete(with: .cancel)
|
||||
}
|
||||
|
||||
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
|
||||
return composeAttachmentsViewController.canPaste(itemProviders)
|
||||
}
|
||||
|
||||
override func paste(itemProviders: [NSItemProvider]) {
|
||||
composeAttachmentsViewController.paste(itemProviders: itemProviders)
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc func showSaveAndClosePrompt() {
|
||||
guard statusTextView.text.trimmingCharacters(in: .whitespacesAndNewlines) != initialText else {
|
||||
close()
|
||||
return
|
||||
}
|
||||
|
||||
if Preferences.shared.automaticallySaveDrafts {
|
||||
saveDraft()
|
||||
close()
|
||||
return
|
||||
}
|
||||
|
||||
let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
|
||||
alert.addAction(UIAlertAction(title: "Save draft", style: .default, handler: { (_) in
|
||||
self.saveDraft()
|
||||
self.close()
|
||||
}))
|
||||
alert.addAction(UIAlertAction(title: "Delete draft", style: .destructive, handler: { (_) in
|
||||
if let currentDraft = self.currentDraft {
|
||||
DraftsManager.shared.remove(currentDraft)
|
||||
}
|
||||
self.close()
|
||||
}))
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
@objc func contentWarningButtonPressed() {
|
||||
contentWarningEnabled = !contentWarningEnabled
|
||||
if contentWarningEnabled {
|
||||
contentWarningTextField.becomeFirstResponder()
|
||||
} else {
|
||||
statusTextView.becomeFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func contentWarningTextFieldDidChange() {
|
||||
updateCharactersRemaining()
|
||||
updateHasChanges()
|
||||
}
|
||||
|
||||
@objc func visibilityButtonPressed() {
|
||||
let alertController = UIAlertController(currentVisibility: self.visibility) { (visibility) in
|
||||
guard let visibility = visibility else { return }
|
||||
self.visibility = visibility
|
||||
}
|
||||
present(alertController, animated: true)
|
||||
}
|
||||
|
||||
@objc func formatButtonPressed(_ button: UIBarButtonItem) {
|
||||
guard statusTextView.isFirstResponder else {
|
||||
return
|
||||
}
|
||||
|
||||
let format = StatusFormat.allCases[button.tag]
|
||||
guard let insertionResult = format.insertionResult else {
|
||||
return
|
||||
}
|
||||
|
||||
let currentSelectedRange = statusTextView.selectedRange
|
||||
if currentSelectedRange.length == 0 {
|
||||
statusTextView.insertText(insertionResult.prefix + insertionResult.suffix)
|
||||
statusTextView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: 0)
|
||||
} else {
|
||||
let start = statusTextView.text.index(statusTextView.text.startIndex, offsetBy: currentSelectedRange.lowerBound)
|
||||
let end = statusTextView.text.index(statusTextView.text.startIndex, offsetBy: currentSelectedRange.upperBound)
|
||||
let selectedText = statusTextView.text[start..<end]
|
||||
statusTextView.insertText(String(insertionResult.prefix + selectedText + insertionResult.suffix))
|
||||
statusTextView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.prefix.count, length: currentSelectedRange.length)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func draftsButtonPressed() {
|
||||
let draftsVC = DraftsTableViewController(account: mastodonController.accountInfo!)
|
||||
draftsVC.delegate = self
|
||||
present(UINavigationController(rootViewController: draftsVC), animated: true)
|
||||
}
|
||||
|
||||
@objc func postButtonPressed() {
|
||||
guard let text = statusTextView.text,
|
||||
!text.isEmpty else { return }
|
||||
|
||||
// save a draft before posting the status, so if a crash occurs during posting, the status won't be lost
|
||||
saveDraft()
|
||||
|
||||
// disable post button while sending post request
|
||||
compositionState.formUnion(.currentlyPosting)
|
||||
|
||||
let contentWarning: String?
|
||||
if contentWarningEnabled, let cwText = contentWarningTextField.text, !cwText.isEmpty {
|
||||
contentWarning = cwText
|
||||
} else {
|
||||
contentWarning = nil
|
||||
}
|
||||
let sensitive = contentWarning != nil
|
||||
let visibility = self.visibility!
|
||||
|
||||
postProgressView.steps = 2 + (composeAttachmentsViewController.attachments.count * 2) // 2 steps (request data, then upload) for each attachment
|
||||
postProgressView.currentStep = 1
|
||||
|
||||
composeAttachmentsViewController.uploadAll(stepProgress: postProgressView.step) { (success, uploadedAttachments) in
|
||||
guard success else { return }
|
||||
|
||||
let request = Client.createStatus(text: text,
|
||||
contentType: Preferences.shared.statusContentType,
|
||||
inReplyTo: self.inReplyToID,
|
||||
media: uploadedAttachments,
|
||||
sensitive: sensitive,
|
||||
spoilerText: contentWarning,
|
||||
visibility: visibility,
|
||||
language: nil)
|
||||
self.mastodonController.run(request) { (response) in
|
||||
guard case let .success(status, _) = response else { fatalError() }
|
||||
self.postedStatus = status
|
||||
// self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: true)
|
||||
|
||||
if let draft = self.currentDraft {
|
||||
DraftsManager.shared.remove(draft)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.postProgressView.step()
|
||||
self.dismiss(animated: true)
|
||||
|
||||
// todo: this doesn't work
|
||||
// let conversationVC = ConversationTableViewController(for: status.id, mastodonController: self.mastodonController)
|
||||
// self.show(conversationVC, sender: self)
|
||||
|
||||
self.xcbSession?.complete(with: .success, additionalData: [
|
||||
"statusURL": status.url?.absoluteString,
|
||||
"statusURI": status.uri
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ComposeViewController: UIScrollViewDelegate {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard let replyView = replyView else { return }
|
||||
|
||||
var constant: CGFloat = 8
|
||||
|
||||
if scrollView.contentOffset.y < 0 {
|
||||
constant -= scrollView.contentOffset.y
|
||||
replyAvatarImageViewTopConstraint?.constant = 8 - scrollView.contentOffset.y
|
||||
} else if scrollView.contentOffset.y > replyView.frame.height - replyView.avatarImageView.frame.height - 16 {
|
||||
constant += replyView.frame.height - replyView.avatarImageView.frame.height - 16 - scrollView.contentOffset.y
|
||||
}
|
||||
|
||||
replyAvatarImageViewTopConstraint?.constant = constant
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeViewController: UITextViewDelegate {
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
updateCharactersRemaining()
|
||||
updatePlaceholder()
|
||||
updateHasChanges()
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeViewController: ComposeAttachmentsViewControllerDelegate {
|
||||
func composeSelectedAttachmentsDidChange() {
|
||||
currentDraft?.attachments = composeAttachmentsViewController.attachments
|
||||
}
|
||||
|
||||
func composeRequiresAttachmentDescriptionsDidChange() {
|
||||
if composeAttachmentsViewController.requiresAttachmentDescriptions {
|
||||
compositionState.formUnion(.requiresAttachmentDescriptions)
|
||||
} else {
|
||||
compositionState.subtract(.requiresAttachmentDescriptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeViewController: DraftsTableViewControllerDelegate {
|
||||
func draftSelectionCanceled() {
|
||||
}
|
||||
|
||||
func shouldSelectDraft(_ draft: DraftsManager.Draft, completion: @escaping (Bool) -> Void) {
|
||||
if draft.inReplyToID != self.inReplyToID, hasChanges {
|
||||
let alertController = UIAlertController(title: "Different Reply", message: "The selected draft is a reply to a different status, do you wish to use it?", preferredStyle: .alert)
|
||||
alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { (_) in
|
||||
completion(false)
|
||||
}))
|
||||
alertController.addAction(UIAlertAction(title: "Restore Draft", style: .default, handler: { (_) in
|
||||
completion(true)
|
||||
}))
|
||||
// we can't present the alert ourselves, since the compose VC is already presenting the draft selector
|
||||
// but presenting on the presented view controller seems hacky, is there a better way to do this?
|
||||
presentedViewController!.present(alertController, animated: true)
|
||||
} else {
|
||||
completion(true)
|
||||
}
|
||||
}
|
||||
|
||||
func draftSelected(_ draft: DraftsManager.Draft) {
|
||||
if hasChanges {
|
||||
saveDraft()
|
||||
}
|
||||
|
||||
self.currentDraft = draft
|
||||
|
||||
inReplyToID = draft.inReplyToID
|
||||
updateInReplyTo()
|
||||
|
||||
statusTextView.text = draft.text
|
||||
contentWarningEnabled = draft.contentWarning != nil
|
||||
contentWarningTextField.text = draft.contentWarning
|
||||
|
||||
updatePlaceholder()
|
||||
updateCharactersRemaining()
|
||||
|
||||
composeAttachmentsViewController.setAttachments(draft.attachments)
|
||||
}
|
||||
|
||||
func draftSelectionCompleted() {
|
||||
// todo: I don't think this can actually happen any more?
|
||||
// check that all the assets from the draft have been added
|
||||
if let currentDraft = currentDraft, composeAttachmentsViewController.attachments.count < currentDraft.attachments.count {
|
||||
// some of the assets in the draft weren't loaded, so notify the user
|
||||
|
||||
let difference = currentDraft.attachments.count - composeAttachmentsViewController.attachments.count
|
||||
// todo: localize me
|
||||
let suffix = difference == 1 ? "" : "s"
|
||||
let verb = difference == 1 ? "was" : "were"
|
||||
let alertController = UIAlertController(title: "Missing Attachments", message: "\(difference) attachment\(suffix) \(verb) removed from the Photos Library and could not be loaded.", preferredStyle: .alert)
|
||||
alertController.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
|
||||
|
||||
present(alertController, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeViewController: UIAdaptivePresentationControllerDelegate {
|
||||
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
|
||||
return Preferences.shared.automaticallySaveDrafts || !hasChanges
|
||||
}
|
||||
|
||||
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
|
||||
showSaveAndClosePrompt()
|
||||
}
|
||||
|
||||
// when the compose screen is dismissed interactively, close() isn't called, so we make sure to
|
||||
// complete the X-Callback-URL session and save the draft is automatic saving is enabled
|
||||
// (if automatic saving is off, the draft will get saved/discarded by the user when didAttemptToDismiss is called
|
||||
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
||||
if Preferences.shared.automaticallySaveDrafts {
|
||||
saveDraft()
|
||||
}
|
||||
xcbSession?.complete(with: .cancel)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16097" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="ComposeViewController" customModule="Tusker" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="charactersRemainingLabel" destination="PMB-Wa-Ht0" id="PN9-wr-Pzu"/>
|
||||
<outlet property="composeAttachmentsContainerView" destination="YFf-I2-7eX" id="u0n-Xe-v09"/>
|
||||
<outlet property="contentView" destination="pcX-rB-RxJ" id="o95-Qa-6N7"/>
|
||||
<outlet property="contentWarningContainerView" destination="kU2-7l-MSy" id="Gnq-Jb-kCA"/>
|
||||
<outlet property="contentWarningTextField" destination="T05-p6-vTz" id="Ivu-Ll-ByO"/>
|
||||
<outlet property="inReplyToContainer" destination="2Dv-Q7-UEA" id="hfG-5j-G5R"/>
|
||||
<outlet property="inReplyToLabel" destination="Y25-eP-tDE" id="9Ei-3s-dAx"/>
|
||||
<outlet property="placeholderLabel" destination="EW3-YK-vPC" id="Rsw-Nv-TNz"/>
|
||||
<outlet property="postProgressView" destination="Tq7-6P-hMT" id="amT-F1-JI0"/>
|
||||
<outlet property="scrollView" destination="6Z0-Vy-hMX" id="ya0-2T-QaV"/>
|
||||
<outlet property="selfDetailView" destination="zZ3-Gv-4P5" id="jou-Vl-TQE"/>
|
||||
<outlet property="stackView" destination="bOB-hF-O9w" id="lD7-b2-MWl"/>
|
||||
<outlet property="statusTextView" destination="9pn-0T-IHb" id="u7j-KW-zCo"/>
|
||||
<outlet property="view" destination="7XG-Dk-OGm" id="09I-sr-hnP"/>
|
||||
</connections>
|
||||
</placeholder>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<view contentMode="scaleToFill" id="7XG-Dk-OGm">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<scrollView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" ambiguous="YES" alwaysBounceVertical="YES" keyboardDismissMode="interactive" translatesAutoresizingMaskIntoConstraints="NO" id="6Z0-Vy-hMX">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="pcX-rB-RxJ">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="371.5"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" ambiguous="YES" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="bOB-hF-O9w">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="419.5"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="6V0-mH-Mhu">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="66"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" placeholderIntrinsicWidth="infinite" placeholderIntrinsicHeight="66" translatesAutoresizingMaskIntoConstraints="NO" id="zZ3-Gv-4P5" customClass="LargeAccountDetailView" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="336" height="66"/>
|
||||
<accessibility key="accessibilityConfiguration">
|
||||
<accessibilityTraits key="traits" notEnabled="YES"/>
|
||||
</accessibility>
|
||||
</view>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="252" verticalHuggingPriority="251" text="500" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="PMB-Wa-Ht0">
|
||||
<rect key="frame" x="336" y="8" width="31" height="20.5"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="PMB-Wa-Ht0" secondAttribute="trailing" constant="8" id="5KJ-rz-Heh"/>
|
||||
<constraint firstItem="zZ3-Gv-4P5" firstAttribute="leading" secondItem="6V0-mH-Mhu" secondAttribute="leading" id="f6Q-fK-zq1"/>
|
||||
<constraint firstItem="zZ3-Gv-4P5" firstAttribute="top" secondItem="6V0-mH-Mhu" secondAttribute="top" id="fjf-mn-l9f"/>
|
||||
<constraint firstItem="PMB-Wa-Ht0" firstAttribute="top" secondItem="6V0-mH-Mhu" secondAttribute="top" constant="8" id="q3V-aY-t9K"/>
|
||||
<constraint firstAttribute="bottom" secondItem="zZ3-Gv-4P5" secondAttribute="bottom" id="rOO-0n-odM"/>
|
||||
<constraint firstItem="PMB-Wa-Ht0" firstAttribute="leading" secondItem="zZ3-Gv-4P5" secondAttribute="trailing" id="sVv-tH-7eB"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="2Dv-Q7-UEA">
|
||||
<rect key="frame" x="0.0" y="66" width="375" height="33.5"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="In reply to Display Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Y25-eP-tDE">
|
||||
<rect key="frame" x="4" y="8" width="367" height="21.5"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="21.5" id="man-Xn-eVt"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="bottom" secondItem="Y25-eP-tDE" secondAttribute="bottom" constant="4" id="1sZ-CX-GDU"/>
|
||||
<constraint firstAttribute="trailing" secondItem="Y25-eP-tDE" secondAttribute="trailing" constant="4" id="I31-Rs-QwW"/>
|
||||
<constraint firstItem="Y25-eP-tDE" firstAttribute="leading" secondItem="2Dv-Q7-UEA" secondAttribute="leading" constant="4" id="kdQ-zs-u7N"/>
|
||||
<constraint firstItem="Y25-eP-tDE" firstAttribute="top" secondItem="2Dv-Q7-UEA" secondAttribute="top" constant="8" id="qdC-S5-CgV"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="kU2-7l-MSy">
|
||||
<rect key="frame" x="0.0" y="99.5" width="375" height="42"/>
|
||||
<subviews>
|
||||
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" borderStyle="roundedRect" placeholder="Write your warning here" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="T05-p6-vTz">
|
||||
<rect key="frame" x="4" y="4" width="367" height="30"/>
|
||||
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor" red="0.94901960780000005" green="0.94901960780000005" blue="0.96862745100000003" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="30" id="yzY-MF-Ukx"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="20"/>
|
||||
<textInputTraits key="textInputTraits"/>
|
||||
</textField>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="T05-p6-vTz" secondAttribute="trailing" constant="4" id="8tG-eW-TG4"/>
|
||||
<constraint firstAttribute="bottom" secondItem="T05-p6-vTz" secondAttribute="bottom" constant="8" id="SUL-Hk-uvM"/>
|
||||
<constraint firstItem="T05-p6-vTz" firstAttribute="leading" secondItem="kU2-7l-MSy" secondAttribute="leading" constant="4" id="WGG-B2-lPC"/>
|
||||
<constraint firstItem="T05-p6-vTz" firstAttribute="top" secondItem="kU2-7l-MSy" secondAttribute="top" constant="4" id="dN2-Pf-qFQ"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="lhQ-ae-pe9">
|
||||
<rect key="frame" x="0.0" y="141.5" width="375" height="150"/>
|
||||
<subviews>
|
||||
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="9pn-0T-IHb">
|
||||
<rect key="frame" x="4" y="0.0" width="367" height="150"/>
|
||||
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor" red="0.94901960780000005" green="0.94901960780000005" blue="0.96862745100000003" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" relation="greaterThanOrEqual" constant="150" id="ISI-jm-FxV"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="20"/>
|
||||
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
||||
</textView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="What's on your mind?" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="EW3-YK-vPC">
|
||||
<rect key="frame" x="8" y="8" width="188" height="24"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="20"/>
|
||||
<color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstAttribute="bottom" secondItem="9pn-0T-IHb" secondAttribute="bottom" id="UAs-fL-Riv"/>
|
||||
<constraint firstItem="9pn-0T-IHb" firstAttribute="leading" secondItem="lhQ-ae-pe9" secondAttribute="leading" constant="4" id="ezI-15-Yd4"/>
|
||||
<constraint firstItem="9pn-0T-IHb" firstAttribute="top" secondItem="lhQ-ae-pe9" secondAttribute="top" id="n8v-pK-I9E"/>
|
||||
<constraint firstItem="EW3-YK-vPC" firstAttribute="leading" secondItem="9pn-0T-IHb" secondAttribute="leading" constant="4" id="n9v-mJ-gz3"/>
|
||||
<constraint firstItem="EW3-YK-vPC" firstAttribute="top" secondItem="9pn-0T-IHb" secondAttribute="top" constant="8" id="q5e-yM-bS4"/>
|
||||
<constraint firstAttribute="trailing" secondItem="9pn-0T-IHb" secondAttribute="trailing" constant="4" id="x7Z-8w-xgm"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="YFf-I2-7eX">
|
||||
<rect key="frame" x="0.0" y="291.5" width="375" height="128"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||
</view>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="bOB-hF-O9w" secondAttribute="trailing" id="GAR-qc-jte"/>
|
||||
<constraint firstAttribute="height" secondItem="bOB-hF-O9w" secondAttribute="height" id="KO2-zF-s7P"/>
|
||||
<constraint firstItem="bOB-hF-O9w" firstAttribute="top" secondItem="pcX-rB-RxJ" secondAttribute="top" id="aBm-Ub-TpI"/>
|
||||
<constraint firstItem="bOB-hF-O9w" firstAttribute="leading" secondItem="pcX-rB-RxJ" secondAttribute="leading" id="yOt-hH-L57"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="pcX-rB-RxJ" firstAttribute="leading" secondItem="6Z0-Vy-hMX" secondAttribute="leading" id="X0f-ja-XBF"/>
|
||||
<constraint firstAttribute="bottom" secondItem="pcX-rB-RxJ" secondAttribute="bottom" id="X8a-PA-r8F"/>
|
||||
<constraint firstAttribute="trailing" secondItem="pcX-rB-RxJ" secondAttribute="trailing" id="lh7-xn-MGp"/>
|
||||
<constraint firstItem="pcX-rB-RxJ" firstAttribute="top" secondItem="6Z0-Vy-hMX" secondAttribute="top" id="yMM-IS-8K1"/>
|
||||
</constraints>
|
||||
</scrollView>
|
||||
<progressView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Tq7-6P-hMT" customClass="SteppedProgressView" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="2"/>
|
||||
<color key="trackTintColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</progressView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="Tq7-6P-hMT" secondAttribute="trailing" id="GeN-8q-weq"/>
|
||||
<constraint firstAttribute="bottom" secondItem="6Z0-Vy-hMX" secondAttribute="bottom" id="Hf3-Cc-mVX"/>
|
||||
<constraint firstItem="Tq7-6P-hMT" firstAttribute="top" secondItem="Heg-g4-sYM" secondAttribute="top" id="LgA-xu-VGE"/>
|
||||
<constraint firstItem="Tq7-6P-hMT" firstAttribute="leading" secondItem="7XG-Dk-OGm" secondAttribute="leading" id="agM-ZO-c3E"/>
|
||||
<constraint firstItem="Heg-g4-sYM" firstAttribute="trailing" secondItem="6Z0-Vy-hMX" secondAttribute="trailing" id="hjY-W6-wTQ"/>
|
||||
<constraint firstItem="bOB-hF-O9w" firstAttribute="width" secondItem="7XG-Dk-OGm" secondAttribute="width" id="i0p-NE-ca1"/>
|
||||
<constraint firstItem="6Z0-Vy-hMX" firstAttribute="leading" secondItem="Heg-g4-sYM" secondAttribute="leading" id="lFF-yC-ql9"/>
|
||||
<constraint firstItem="6Z0-Vy-hMX" firstAttribute="top" secondItem="Heg-g4-sYM" secondAttribute="top" id="osv-zq-seP"/>
|
||||
</constraints>
|
||||
<viewLayoutGuide key="safeArea" id="Heg-g4-sYM"/>
|
||||
<point key="canvasLocation" x="140" y="154"/>
|
||||
</view>
|
||||
</objects>
|
||||
</document>
|
|
@ -1,185 +0,0 @@
|
|||
//
|
||||
// CompositionAttachment.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 1/1/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Photos
|
||||
import MobileCoreServices
|
||||
|
||||
enum CompositionAttachmentData {
|
||||
case asset(PHAsset)
|
||||
case image(UIImage)
|
||||
case video(URL)
|
||||
|
||||
var type: AttachmentType {
|
||||
switch self {
|
||||
case let .asset(asset):
|
||||
return asset.attachmentType!
|
||||
case .image(_):
|
||||
return .image
|
||||
case .video(_):
|
||||
return .video
|
||||
}
|
||||
}
|
||||
|
||||
var isAsset: Bool {
|
||||
switch self {
|
||||
case .asset(_):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var canSaveToDraft: Bool {
|
||||
switch self {
|
||||
case .video(_):
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func getData(completion: @escaping (Data, String) -> Void) {
|
||||
switch self {
|
||||
case let .image(image):
|
||||
completion(image.pngData()!, "image/png")
|
||||
case let .asset(asset):
|
||||
if asset.mediaType == .image {
|
||||
let options = PHImageRequestOptions()
|
||||
options.version = .current
|
||||
options.deliveryMode = .highQualityFormat
|
||||
options.resizeMode = .none
|
||||
options.isNetworkAccessAllowed = true
|
||||
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { (data, dataUTI, orientation, info) in
|
||||
guard var data = data, let dataUTI = dataUTI else { fatalError() }
|
||||
|
||||
let mimeType: String
|
||||
if dataUTI == "public.heic" {
|
||||
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
|
||||
let image = CIImage(data: data)!
|
||||
let context = CIContext()
|
||||
let colorSpace = image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!
|
||||
data = context.jpegRepresentation(of: image, colorSpace: colorSpace, options: [:])!
|
||||
mimeType = "image/jpeg"
|
||||
} else {
|
||||
mimeType = UTTypeCopyPreferredTagWithClass(dataUTI as CFString, kUTTagClassMIMEType)!.takeRetainedValue() as String
|
||||
}
|
||||
|
||||
completion(data, mimeType)
|
||||
}
|
||||
} else if asset.mediaType == .video {
|
||||
let options = PHVideoRequestOptions()
|
||||
options.deliveryMode = .automatic
|
||||
options.isNetworkAccessAllowed = true
|
||||
options.version = .current
|
||||
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { (exportSession, info) in
|
||||
guard let exportSession = exportSession else { fatalError("failed to create export session") }
|
||||
CompositionAttachmentData.exportVideoData(session: exportSession, completion: completion)
|
||||
}
|
||||
} else {
|
||||
fatalError("assetType must be either image or video")
|
||||
}
|
||||
case let .video(url):
|
||||
let asset = AVURLAsset(url: url)
|
||||
guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
|
||||
fatalError("failed to create export session")
|
||||
}
|
||||
CompositionAttachmentData.exportVideoData(session: session, completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Data, String) -> Void) {
|
||||
session.outputFileType = .mp4
|
||||
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
|
||||
session.exportAsynchronously {
|
||||
guard session.status == .completed else { fatalError("video export failed: \(String(describing: session.error))") }
|
||||
do {
|
||||
let data = try Data(contentsOf: session.outputURL!)
|
||||
completion(data, "video/mp4")
|
||||
} catch {
|
||||
fatalError("Unable to load video: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AttachmentType {
|
||||
case image, video
|
||||
}
|
||||
}
|
||||
|
||||
extension PHAsset {
|
||||
var attachmentType: CompositionAttachmentData.AttachmentType? {
|
||||
switch self.mediaType {
|
||||
case .image:
|
||||
return .image
|
||||
case .video:
|
||||
return .video
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CompositionAttachmentData: Codable {
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
switch self {
|
||||
case let .asset(asset):
|
||||
try container.encode("asset", forKey: .type)
|
||||
try container.encode(asset.localIdentifier, forKey: .assetIdentifier)
|
||||
case let .image(image):
|
||||
try container.encode("image", forKey: .type)
|
||||
try container.encode(image.pngData()!, forKey: .imageData)
|
||||
case .video(_):
|
||||
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: [], debugDescription: "video CompositionAttachments cannot be encoded"))
|
||||
}
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
switch try container.decode(String.self, forKey: .type) {
|
||||
case "asset":
|
||||
let identifier = try container.decode(String.self, forKey: .assetIdentifier)
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil).firstObject else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .assetIdentifier, in: container, debugDescription: "Could not fetch asset with local identifier")
|
||||
}
|
||||
self = .asset(asset)
|
||||
case "image":
|
||||
guard let image = UIImage(data: try container.decode(Data.self, forKey: .imageData)) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "Could not decode UIImage from image data")
|
||||
}
|
||||
self = .image(image)
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of 'image' or 'asset'")
|
||||
}
|
||||
}
|
||||
|
||||
enum CodingKeys: CodingKey {
|
||||
case type
|
||||
case imageData
|
||||
/// The local identifier of the PHAsset for this attachment
|
||||
case assetIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
extension CompositionAttachmentData: Equatable {
|
||||
static func ==(lhs: CompositionAttachmentData, rhs: CompositionAttachmentData) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case let (.asset(a), .asset(b)):
|
||||
return a.localIdentifier == b.localIdentifier
|
||||
case let (.image(a), .image(b)):
|
||||
return a == b
|
||||
case let (.video(a), .video(b)):
|
||||
return a == b
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,217 @@
|
|||
//
|
||||
// MainComposeTextView.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/29/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
|
||||
struct MainComposeTextView: View {
|
||||
@ObservedObject var draft: Draft
|
||||
let placeholder: Text
|
||||
|
||||
let minHeight: CGFloat = 150
|
||||
@State private var height: CGFloat?
|
||||
@State private var becomeFirstResponder: Bool = false
|
||||
@State private var hasFirstAppeared = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
MainComposeWrappedTextView(
|
||||
text: $draft.text,
|
||||
visibility: draft.visibility,
|
||||
becomeFirstResponder: $becomeFirstResponder
|
||||
) { (textView) in
|
||||
self.height = max(textView.contentSize.height, minHeight)
|
||||
}
|
||||
.frame(height: height ?? minHeight)
|
||||
|
||||
if draft.text.isEmpty {
|
||||
placeholder
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(.secondary)
|
||||
.offset(x: 4, y: 8)
|
||||
}
|
||||
}.onAppear {
|
||||
if !hasFirstAppeared {
|
||||
hasFirstAppeared = true
|
||||
becomeFirstResponder = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MainComposeWrappedTextView: UIViewRepresentable {
|
||||
typealias UIViewType = UITextView
|
||||
|
||||
@Binding var text: String
|
||||
let visibility: Status.Visibility
|
||||
@Binding var becomeFirstResponder: Bool
|
||||
var textDidChange: (UITextView) -> Void
|
||||
|
||||
@EnvironmentObject var uiState: ComposeUIState
|
||||
@State var visibilityButton: UIBarButtonItem?
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let textView = UITextView()
|
||||
textView.delegate = context.coordinator
|
||||
textView.isEditable = true
|
||||
textView.backgroundColor = .secondarySystemBackground
|
||||
textView.font = .systemFont(ofSize: 20)
|
||||
textView.textContainer.lineBreakMode = .byWordWrapping
|
||||
context.coordinator.textView = textView
|
||||
|
||||
let visibilityAction: Selector?
|
||||
if #available(iOS 14.0, *) {
|
||||
visibilityAction = nil
|
||||
} else {
|
||||
visibilityAction = #selector(ComposeHostingController.visibilityButtonPressed(_:))
|
||||
}
|
||||
let visibilityButton = UIBarButtonItem(image: UIImage(systemName: visibility.imageName), style: .plain, target: nil, action: visibilityAction)
|
||||
updateVisibilityMenu(visibilityButton)
|
||||
let toolbar = UIToolbar()
|
||||
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
||||
toolbar.items = [
|
||||
UIBarButtonItem(title: "CW", style: .plain, target: nil, action: #selector(ComposeHostingController.cwButtonPressed)),
|
||||
visibilityButton,
|
||||
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
|
||||
] + createFormattingButtons(coordinator: context.coordinator) + [
|
||||
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
|
||||
UIBarButtonItem(title: "Drafts", style: .plain, target: nil, action: #selector(ComposeHostingController.draftsButtonPresed)),
|
||||
]
|
||||
textView.inputAccessoryView = toolbar
|
||||
// can't modify @State during view update
|
||||
DispatchQueue.main.async {
|
||||
self.visibilityButton = visibilityButton
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(context.coordinator, selector: #selector(Coordinator.keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(context.coordinator, selector: #selector(Coordinator.keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(context.coordinator, selector: #selector(Coordinator.keyboardDidHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil)
|
||||
|
||||
return textView
|
||||
}
|
||||
|
||||
private func createFormattingButtons(coordinator: Coordinator) -> [UIBarButtonItem] {
|
||||
guard Preferences.shared.statusContentType != .plain else {
|
||||
return []
|
||||
}
|
||||
|
||||
var formatButtons = StatusFormat.allCases.map { (format) -> UIBarButtonItem in
|
||||
let item: UIBarButtonItem
|
||||
if let image = format.image {
|
||||
item = UIBarButtonItem(image: image, style: .plain, target: coordinator, action: #selector(Coordinator.formatButtonPressed(_:)))
|
||||
} else if let (str, attributes) = format.title {
|
||||
item = UIBarButtonItem(title: str, style: .plain, target: coordinator, action: #selector(Coordinator.formatButtonPressed(_:)))
|
||||
item.setTitleTextAttributes(attributes, for: .normal)
|
||||
item.setTitleTextAttributes(attributes, for: .highlighted)
|
||||
} else {
|
||||
fatalError("StatusFormat must have either an image or a title")
|
||||
}
|
||||
item.tag = StatusFormat.allCases.firstIndex(of: format)!
|
||||
item.accessibilityLabel = format.accessibilityLabel
|
||||
return item
|
||||
}
|
||||
|
||||
for i in (1..<StatusFormat.allCases.count).reversed() {
|
||||
let spacer = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil)
|
||||
spacer.width = 8
|
||||
formatButtons.insert(spacer, at: i)
|
||||
}
|
||||
|
||||
return formatButtons
|
||||
}
|
||||
|
||||
private func updateVisibilityMenu(_ visibilityButton: UIBarButtonItem) {
|
||||
if #available(iOS 14.0, *) {
|
||||
let elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in
|
||||
let state = visibility == self.visibility ? UIMenuElement.State.on : .off
|
||||
return UIAction(title: visibility.displayName, image: UIImage(systemName: visibility.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: state) { (_) in
|
||||
self.uiState.draft.visibility = visibility
|
||||
}
|
||||
}
|
||||
visibilityButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: elements)
|
||||
}
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UITextView, context: Context) {
|
||||
uiView.text = text
|
||||
if let visibilityButton = visibilityButton {
|
||||
visibilityButton.image = UIImage(systemName: visibility.imageName)
|
||||
updateVisibilityMenu(visibilityButton)
|
||||
}
|
||||
context.coordinator.text = $text
|
||||
context.coordinator.didChange = textDidChange
|
||||
context.coordinator.uiState = uiState
|
||||
|
||||
if becomeFirstResponder {
|
||||
DispatchQueue.main.async {
|
||||
// calling becomeFirstResponder during the SwiftUI update causes a crash on iOS 13
|
||||
uiView.becomeFirstResponder()
|
||||
// can't update @State vars during the SwiftUI update
|
||||
becomeFirstResponder = false
|
||||
}
|
||||
}
|
||||
|
||||
// wait until the next runloop iteration so that SwiftUI view updates have finished and
|
||||
// the text view knows its new content size
|
||||
DispatchQueue.main.async {
|
||||
self.textDidChange(uiView)
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
return Coordinator(text: $text, uiState: uiState, didChange: textDidChange)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UITextViewDelegate {
|
||||
weak var textView: UITextView?
|
||||
var text: Binding<String>
|
||||
var didChange: (UITextView) -> Void
|
||||
var uiState: ComposeUIState
|
||||
|
||||
init(text: Binding<String>, uiState: ComposeUIState, didChange: @escaping (UITextView) -> Void) {
|
||||
self.text = text
|
||||
self.didChange = didChange
|
||||
self.uiState = uiState
|
||||
}
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
text.wrappedValue = textView.text
|
||||
didChange(textView)
|
||||
}
|
||||
|
||||
@objc func formatButtonPressed(_ sender: UIBarButtonItem) {
|
||||
guard let textView = textView, textView.isFirstResponder else { return }
|
||||
let format = StatusFormat.allCases[sender.tag]
|
||||
guard let insertionResult = format.insertionResult else { return }
|
||||
|
||||
let currentSelectedRange = textView.selectedRange
|
||||
if currentSelectedRange.length == 0 {
|
||||
textView.insertText(insertionResult.prefix + insertionResult.suffix)
|
||||
textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: 0)
|
||||
} else {
|
||||
let start = textView.text.index(textView.text.startIndex, offsetBy: currentSelectedRange.lowerBound)
|
||||
let end = textView.text.index(textView.text.startIndex, offsetBy: currentSelectedRange.upperBound)
|
||||
let selectedText = textView.text[start..<end]
|
||||
textView.insertText(String(insertionResult.prefix + selectedText + insertionResult.suffix))
|
||||
textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.prefix.utf16.count, length: currentSelectedRange.length)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func keyboardWillShow(_ notification: Foundation.Notification) {
|
||||
uiState.delegate?.keyboardWillShow(accessoryView: textView!.inputAccessoryView!, notification: notification)
|
||||
}
|
||||
|
||||
@objc func keyboardWillHide(_ notification: Foundation.Notification) {
|
||||
uiState.delegate?.keyboardWillHide(accessoryView: textView!.inputAccessoryView!, notification: notification)
|
||||
}
|
||||
|
||||
@objc func keyboardDidHide(_ notification: Foundation.Notification) {
|
||||
uiState.delegate?.keyboardDidHide(accessoryView: textView!.inputAccessoryView!, notification: notification)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -189,7 +189,7 @@ extension ConversationTableViewController: UITableViewDataSourcePrefetching {
|
|||
for indexPath in indexPaths {
|
||||
guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else { continue }
|
||||
_ = ImageCache.avatars.get(status.account.avatar, completion: nil)
|
||||
for attachment in status.attachments {
|
||||
for attachment in status.attachments where attachment.kind == .image {
|
||||
_ = ImageCache.attachments.get(attachment.url, completion: nil)
|
||||
}
|
||||
}
|
||||
|
@ -199,7 +199,7 @@ extension ConversationTableViewController: UITableViewDataSourcePrefetching {
|
|||
for indexPath in indexPaths {
|
||||
guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else { continue }
|
||||
ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
|
||||
for attachment in status.attachments {
|
||||
for attachment in status.attachments where attachment.kind == .image {
|
||||
ImageCache.attachments.cancelWithoutCallback(attachment.url)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,20 +10,22 @@ import UIKit
|
|||
|
||||
protocol DraftsTableViewControllerDelegate: class {
|
||||
func draftSelectionCanceled()
|
||||
func shouldSelectDraft(_ draft: DraftsManager.Draft, completion: @escaping (Bool) -> Void)
|
||||
func draftSelected(_ draft: DraftsManager.Draft)
|
||||
func shouldSelectDraft(_ draft: Draft, completion: @escaping (Bool) -> Void)
|
||||
func draftSelected(_ draft: Draft)
|
||||
func draftSelectionCompleted()
|
||||
}
|
||||
|
||||
class DraftsTableViewController: UITableViewController {
|
||||
|
||||
let account: LocalData.UserAccountInfo
|
||||
let excludedDraft: Draft?
|
||||
weak var delegate: DraftsTableViewControllerDelegate?
|
||||
|
||||
var drafts = [DraftsManager.Draft]()
|
||||
var drafts = [Draft]()
|
||||
|
||||
init(account: LocalData.UserAccountInfo) {
|
||||
init(account: LocalData.UserAccountInfo, exclude: Draft? = nil) {
|
||||
self.account = account
|
||||
self.excludedDraft = exclude
|
||||
|
||||
super.init(nibName: "DraftsTableViewController", bundle: nil)
|
||||
|
||||
|
@ -44,11 +46,11 @@ class DraftsTableViewController: UITableViewController {
|
|||
tableView.register(UINib(nibName: "DraftTableViewCell", bundle: nil), forCellReuseIdentifier: "draftCell")
|
||||
|
||||
drafts = DraftsManager.shared.sorted.filter { (draft) in
|
||||
draft.accountID == account.id
|
||||
draft.accountID == account.id && draft != excludedDraft
|
||||
}
|
||||
}
|
||||
|
||||
func draft(for indexPath: IndexPath) -> DraftsManager.Draft {
|
||||
func draft(for indexPath: IndexPath) -> Draft {
|
||||
return drafts[indexPath.row]
|
||||
}
|
||||
|
||||
|
|
|
@ -19,6 +19,8 @@ class ExploreViewController: EnhancedTableViewController {
|
|||
var resultsController: SearchResultsViewController!
|
||||
var searchController: UISearchController!
|
||||
|
||||
var searchControllerStatusOnAppearance: Bool? = nil
|
||||
|
||||
init(mastodonController: MastodonController) {
|
||||
self.mastodonController = mastodonController
|
||||
|
||||
|
@ -90,9 +92,7 @@ class ExploreViewController: EnhancedTableViewController {
|
|||
snapshot.appendItems(SavedDataManager.shared.sortedHashtags(for: account).map { .savedHashtag($0) } + [.addSavedHashtag], toSection: .savedHashtags)
|
||||
snapshot.appendItems(SavedDataManager.shared.savedInstances(for: account).map { .savedInstance($0) } + [.findInstance], toSection: .savedInstances)
|
||||
// the initial, static items should not be displayed with an animation
|
||||
UIView.performWithoutAnimation {
|
||||
dataSource.apply(snapshot)
|
||||
}
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
|
||||
resultsController = SearchResultsViewController(mastodonController: mastodonController)
|
||||
resultsController.exploreNavigationController = self.navigationController!
|
||||
|
@ -111,6 +111,18 @@ class ExploreViewController: EnhancedTableViewController {
|
|||
reloadLists()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
// this is a workaround for the issue that setting isActive on a search controller that is not visible
|
||||
// does not cause it to automatically become active once it becomes visible
|
||||
// see FB7814561
|
||||
if let active = searchControllerStatusOnAppearance {
|
||||
searchController.isActive = active
|
||||
searchControllerStatusOnAppearance = nil
|
||||
}
|
||||
}
|
||||
|
||||
func reloadLists() {
|
||||
let request = Client.getLists()
|
||||
mastodonController.run(request) { (response) in
|
||||
|
|
|
@ -46,6 +46,7 @@ extension FindInstanceViewController: InstanceSelectorTableViewControllerDelegat
|
|||
func didSelectInstance(url: URL) {
|
||||
let instanceTimelineController = InstanceTimelineViewController(for: url, parentMastodonController: parentMastodonController!)
|
||||
instanceTimelineController.delegate = instanceTimelineDelegate
|
||||
instanceTimelineController.browsingEnabled = false
|
||||
show(instanceTimelineController, sender: self)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,6 +46,8 @@ class LargeImageImageContentView: UIImageView, GIFAnimatable, LargeImageContentV
|
|||
}
|
||||
|
||||
override public func display(_ layer: CALayer) {
|
||||
super.display(layer)
|
||||
|
||||
updateImageIfNeeded()
|
||||
}
|
||||
}
|
||||
|
@ -76,6 +78,8 @@ class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView {
|
|||
super.init(asset: asset, gravity: .resizeAspect)
|
||||
|
||||
self.animationImage = source.image
|
||||
|
||||
self.player.play()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
|
|
|
@ -13,6 +13,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
typealias ContentView = UIView & LargeImageContentView
|
||||
|
||||
weak var animationSourceView: UIImageView?
|
||||
var largeImageController: LargeImageViewController? { self }
|
||||
var animationImage: UIImage? { contentView.animationImage }
|
||||
var animationGifData: Data? { contentView.animationGifData }
|
||||
var dismissInteractionController: LargeImageInteractionController?
|
||||
|
@ -30,7 +31,12 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
@IBOutlet weak var bottomControlsView: UIView!
|
||||
@IBOutlet weak var descriptionLabel: UILabel!
|
||||
|
||||
var contentView: ContentView
|
||||
var contentView: ContentView {
|
||||
didSet {
|
||||
oldValue.removeFromSuperview()
|
||||
setupContentView()
|
||||
}
|
||||
}
|
||||
var contentViewLeadingConstraint: NSLayoutConstraint!
|
||||
var contentViewTopConstraint: NSLayoutConstraint!
|
||||
|
||||
|
@ -49,6 +55,9 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
override var prefersStatusBarHidden: Bool {
|
||||
return true
|
||||
}
|
||||
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
|
||||
return .none
|
||||
}
|
||||
|
||||
override var prefersHomeIndicatorAutoHidden: Bool {
|
||||
return !controlsVisible
|
||||
|
@ -72,22 +81,16 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||
scrollView.addSubview(contentView)
|
||||
contentViewLeadingConstraint = contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor)
|
||||
contentViewTopConstraint = contentView.topAnchor.constraint(equalTo: scrollView.topAnchor)
|
||||
NSLayoutConstraint.activate([
|
||||
contentViewLeadingConstraint,
|
||||
contentViewTopConstraint,
|
||||
])
|
||||
setupContentView()
|
||||
|
||||
setControlsVisible(initialControlsVisible, animated: false)
|
||||
shareButton.isEnabled = !contentView.activityItemsForSharing.isEmpty
|
||||
|
||||
scrollView.delegate = self
|
||||
|
||||
if let imageDescription = imageDescription {
|
||||
descriptionLabel.text = imageDescription
|
||||
if let imageDescription = imageDescription,
|
||||
!imageDescription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
descriptionLabel.text = imageDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
} else {
|
||||
bottomControlsView.isHidden = true
|
||||
}
|
||||
|
@ -102,6 +105,17 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
view.addGestureRecognizer(doubleTap)
|
||||
}
|
||||
|
||||
private func setupContentView() {
|
||||
contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||
scrollView.addSubview(contentView)
|
||||
contentViewLeadingConstraint = contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor)
|
||||
contentViewTopConstraint = contentView.topAnchor.constraint(equalTo: scrollView.topAnchor)
|
||||
NSLayoutConstraint.activate([
|
||||
contentViewLeadingConstraint,
|
||||
contentViewTopConstraint,
|
||||
])
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
|
@ -119,8 +133,9 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
centerImage()
|
||||
|
||||
// todo: does this need to be in viewDidLayoutSubviews?
|
||||
if view.safeAreaInsets.top == 44 {
|
||||
// running on iPhone X style notched device
|
||||
// on iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max, the top safe area inset is 44pts
|
||||
// on iPhone XR, 11, the top inset is 48pts
|
||||
if view.safeAreaInsets.top == 44 || view.safeAreaInsets.top == 48 {
|
||||
let notchWidth: CGFloat = 209
|
||||
let earWidth = (view.bounds.width - notchWidth) / 2
|
||||
let offset = (earWidth - shareButton.bounds.width) / 2
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16097" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
|
@ -70,23 +71,25 @@
|
|||
</constraints>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="rPa-Zu-T6g">
|
||||
<rect key="frame" x="0.0" y="630.5" width="375" height="36.5"/>
|
||||
<rect key="frame" x="0.0" y="622.5" width="375" height="44.5"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="eo5-fc-RV8">
|
||||
<rect key="frame" x="16" y="0.0" width="343" height="20.5"/>
|
||||
<rect key="frame" x="16" y="8" width="343" height="20.5"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.5" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="eo5-fc-RV8" firstAttribute="top" secondItem="rPa-Zu-T6g" secondAttribute="top" constant="8" id="6n3-E0-2G6"/>
|
||||
<constraint firstAttribute="trailing" secondItem="eo5-fc-RV8" secondAttribute="trailing" constant="16" id="6uL-vY-tqk"/>
|
||||
<constraint firstItem="eo5-fc-RV8" firstAttribute="leading" secondItem="rPa-Zu-T6g" secondAttribute="leading" constant="16" id="KIF-vw-K7n"/>
|
||||
<constraint firstAttribute="height" secondItem="eo5-fc-RV8" secondAttribute="height" constant="16" id="bt3-XT-WzC"/>
|
||||
<constraint firstAttribute="bottom" secondItem="eo5-fc-RV8" secondAttribute="bottom" constant="16" id="v43-mS-tyR"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="w1g-VC-Ll9"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<gestureRecognizers/>
|
||||
<constraints>
|
||||
|
@ -101,7 +104,6 @@
|
|||
<constraint firstItem="Skj-xq-AgQ" firstAttribute="height" secondItem="BJw-5C-9nT" secondAttribute="height" id="jvz-QW-n9c"/>
|
||||
<constraint firstItem="kHo-B9-R7a" firstAttribute="top" secondItem="BJw-5C-9nT" secondAttribute="top" id="n1O-C3-yQR"/>
|
||||
</constraints>
|
||||
<viewLayoutGuide key="safeArea" id="w1g-VC-Ll9"/>
|
||||
<point key="canvasLocation" x="-164" y="476"/>
|
||||
</view>
|
||||
</objects>
|
||||
|
|
|
@ -10,14 +10,16 @@ import Pachyderm
|
|||
|
||||
class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableViewController {
|
||||
|
||||
private var attachment: Attachment?
|
||||
let url: URL
|
||||
let cache: ImageCache
|
||||
let imageDescription: String?
|
||||
|
||||
var largeImageVC: LargeImageViewController?
|
||||
var loadingVC: LoadingViewController?
|
||||
private(set) var loaded = false
|
||||
private(set) var largeImageVC: LargeImageViewController?
|
||||
private var loadingVC: LoadingViewController?
|
||||
|
||||
var imageRequest: ImageCache.Request?
|
||||
private var imageRequest: ImageCache.Request?
|
||||
|
||||
private var initialControlsVisible: Bool = true
|
||||
var controlsVisible: Bool {
|
||||
|
@ -36,6 +38,7 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
|
|||
var shrinkGestureEnabled = true
|
||||
|
||||
weak var animationSourceView: UIImageView?
|
||||
var largeImageController: LargeImageViewController? { largeImageVC }
|
||||
var animationImage: UIImage? { largeImageVC?.animationImage ?? animationSourceView?.image }
|
||||
var animationGifData: Data? { largeImageVC?.animationGifData }
|
||||
var dismissInteractionController: LargeImageInteractionController?
|
||||
|
@ -43,6 +46,9 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
|
|||
override var prefersStatusBarHidden: Bool {
|
||||
return true
|
||||
}
|
||||
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
|
||||
return .none
|
||||
}
|
||||
override var childForHomeIndicatorAutoHidden: UIViewController? {
|
||||
return largeImageVC
|
||||
}
|
||||
|
@ -66,6 +72,7 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
|
|||
|
||||
convenience init(attachment: Attachment) {
|
||||
self.init(url: attachment.url, cache: .attachments, imageDescription: attachment.description)
|
||||
self.attachment = attachment
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
|
@ -81,6 +88,8 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
|
|||
if let data = cache.get(url) {
|
||||
createLargeImage(data: data)
|
||||
} else {
|
||||
createPreview()
|
||||
|
||||
loadingVC = LoadingViewController()
|
||||
embedChild(loadingVC!)
|
||||
imageRequest = cache.get(url) { [weak self] (data) in
|
||||
|
@ -106,15 +115,32 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
|
|||
}
|
||||
}
|
||||
|
||||
func createLargeImage(data: Data) {
|
||||
private func createLargeImage(data: Data) {
|
||||
guard !loaded else { return }
|
||||
loaded = true
|
||||
guard let image = UIImage(data: data) else { return }
|
||||
let gifData = url.pathExtension == "gif" ? data : nil
|
||||
createLargeImage(image: image, gifData: gifData)
|
||||
}
|
||||
|
||||
private func createLargeImage(image: UIImage, gifData: Data?) {
|
||||
let imageView = LargeImageImageContentView(image: image, gifData: gifData)
|
||||
|
||||
if let existing = largeImageVC {
|
||||
existing.contentView = imageView
|
||||
} else {
|
||||
largeImageVC = LargeImageViewController(contentView: imageView, description: imageDescription, sourceView: animationSourceView)
|
||||
largeImageVC!.initialControlsVisible = initialControlsVisible
|
||||
largeImageVC!.shrinkGestureEnabled = false
|
||||
embedChild(largeImageVC!)
|
||||
}
|
||||
}
|
||||
|
||||
private func createPreview() {
|
||||
guard !self.loaded,
|
||||
let image = animationSourceView?.image else { return }
|
||||
|
||||
self.createLargeImage(image: image, gifData: nil)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import Gifu
|
|||
|
||||
protocol LargeImageAnimatableViewController: UIViewController {
|
||||
var animationSourceView: UIImageView? { get }
|
||||
var largeImageController: LargeImageViewController? { get }
|
||||
var animationImage: UIImage? { get }
|
||||
var animationGifData: Data? { get }
|
||||
var dismissInteractionController: LargeImageInteractionController? { get }
|
||||
|
@ -37,7 +38,11 @@ extension LargeImageAnimatableViewController {
|
|||
class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
|
||||
|
||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||
if UIAccessibility.prefersCrossFadeTransitionsBackwardsCompat {
|
||||
return 0.2
|
||||
} else {
|
||||
return 0.4
|
||||
}
|
||||
}
|
||||
|
||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
|
@ -46,6 +51,11 @@ class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTra
|
|||
return
|
||||
}
|
||||
|
||||
if UIAccessibility.prefersCrossFadeTransitionsBackwardsCompat {
|
||||
animateCrossFadeTransition(using: transitionContext)
|
||||
return
|
||||
}
|
||||
|
||||
let containerView = transitionContext.containerView
|
||||
containerView.addSubview(toVC.view)
|
||||
|
||||
|
@ -58,8 +68,11 @@ class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTra
|
|||
return
|
||||
}
|
||||
|
||||
// use alpha, becaus isHidden makes stack views re-layout
|
||||
// use alpha, because isHidden makes stack views re-layout
|
||||
sourceView.alpha = 0
|
||||
toVC.view.alpha = 0
|
||||
toVC.largeImageController?.contentView.isHidden = true
|
||||
toVC.largeImageController?.setControlsVisible(false, animated: false)
|
||||
|
||||
var finalFrameSize = finalVCFrame.inset(by: fromVC.view.safeAreaInsets).size
|
||||
let newWidth = finalFrameSize.width / image.size.width
|
||||
|
@ -81,21 +94,16 @@ class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTra
|
|||
imageView.layer.maskedCorners = sourceView.layer.maskedCorners
|
||||
imageView.layer.masksToBounds = true
|
||||
|
||||
let blackView = UIView(frame: finalVCFrame)
|
||||
blackView.backgroundColor = .black
|
||||
blackView.alpha = 0
|
||||
|
||||
containerView.addSubview(blackView)
|
||||
containerView.addSubview(imageView)
|
||||
|
||||
toVC.view.isHidden = true
|
||||
|
||||
let duration = transitionDuration(using: transitionContext)
|
||||
UIView.animate(withDuration: duration, animations: {
|
||||
let velocity = 1 / CGFloat(duration)
|
||||
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.75, initialSpringVelocity: velocity, options: []) {
|
||||
imageView.frame = finalFrame
|
||||
imageView.layer.cornerRadius = 0
|
||||
blackView.alpha = 1
|
||||
}, completion: { _ in
|
||||
toVC.view.alpha = 1
|
||||
toVC.largeImageController?.setControlsVisible(true, animated: false)
|
||||
} completion: { (_) in
|
||||
// This shouldn't be necessary. I believe it's a workaround for using a XIB
|
||||
// for the large image VC. Without this, the final frame of the large image VC
|
||||
// is not set to the propper rect (it uses the frame of the preview device
|
||||
|
@ -103,15 +111,30 @@ class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTra
|
|||
// (or UIKit does layout differently when loading the view) and this is not necessary.
|
||||
toVC.view.frame = finalVCFrame
|
||||
|
||||
toVC.view.isHidden = false
|
||||
toVC.largeImageController?.contentView.isHidden = false
|
||||
fromVC.view.isHidden = false
|
||||
blackView.removeFromSuperview()
|
||||
imageView.removeFromSuperview()
|
||||
|
||||
sourceView.alpha = 1
|
||||
|
||||
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func animateCrossFadeTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
guard let toVC = transitionContext.viewController(forKey: .to) as? LargeImageAnimatableViewController else {
|
||||
return
|
||||
}
|
||||
|
||||
transitionContext.containerView.addSubview(toVC.view)
|
||||
toVC.view.alpha = 0
|
||||
|
||||
let duration = transitionDuration(using: transitionContext)
|
||||
UIView.animate(withDuration: duration) {
|
||||
toVC.view.alpha = 1
|
||||
} completion: { (_) in
|
||||
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -27,6 +27,11 @@ class LargeImageShrinkAnimationController: NSObject, UIViewControllerAnimatedTra
|
|||
return
|
||||
}
|
||||
|
||||
if UIAccessibility.prefersCrossFadeTransitionsBackwardsCompat && !transitionContext.isInteractive {
|
||||
animateCrossFadeTransition(using: transitionContext)
|
||||
return
|
||||
}
|
||||
|
||||
guard let sourceView = fromVC.animationSourceView,
|
||||
let sourceFrame = fromVC.sourceViewFrame(in: toVC.view),
|
||||
let image = fromVC.animationImage else {
|
||||
|
@ -86,4 +91,21 @@ class LargeImageShrinkAnimationController: NSObject, UIViewControllerAnimatedTra
|
|||
})
|
||||
}
|
||||
|
||||
func animateCrossFadeTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
guard let fromVC = transitionContext.viewController(forKey: .from) as? LargeImageAnimatableViewController,
|
||||
let toVC = transitionContext.viewController(forKey: .to) else {
|
||||
return
|
||||
}
|
||||
|
||||
transitionContext.containerView.addSubview(toVC.view)
|
||||
transitionContext.containerView.addSubview(fromVC.view)
|
||||
|
||||
let duration = transitionDuration(using: transitionContext)
|
||||
UIView.animate(withDuration: duration) {
|
||||
fromVC.view.alpha = 0
|
||||
} completion: { (_) in
|
||||
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,383 @@
|
|||
//
|
||||
// MainSidebarViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 6/24/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
#if SDK_IOS_14
|
||||
@available(iOS 14.0, *)
|
||||
protocol MainSidebarViewControllerDelegate: class {
|
||||
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item)
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
class MainSidebarViewController: UIViewController {
|
||||
|
||||
private weak var mastodonController: MastodonController!
|
||||
|
||||
weak var sidebarDelegate: MainSidebarViewControllerDelegate?
|
||||
|
||||
private var collectionView: UICollectionView!
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
|
||||
var allItems: [Item] {
|
||||
[
|
||||
.tab(.timelines),
|
||||
.tab(.notifications),
|
||||
.tab(.myProfile),
|
||||
] + exploreTabItems
|
||||
}
|
||||
|
||||
var exploreTabItems: [Item] {
|
||||
var items: [Item] = [.search, .bookmarks]
|
||||
let snapshot = dataSource.snapshot()
|
||||
for case let .list(list) in snapshot.itemIdentifiers(inSection: .lists) {
|
||||
items.append(.list(list))
|
||||
}
|
||||
for case let .savedHashtag(hashtag) in snapshot.itemIdentifiers(inSection: .savedHashtags) {
|
||||
items.append(.savedHashtag(hashtag))
|
||||
}
|
||||
for case let .savedInstance(instance) in snapshot.itemIdentifiers(inSection: .savedInstances) {
|
||||
items.append(.savedInstance(instance))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private(set) var previouslySelectedItem: Item?
|
||||
var selectedItem: Item? {
|
||||
guard let indexPath = collectionView.indexPathsForSelectedItems?.first else {
|
||||
return nil
|
||||
}
|
||||
return dataSource.itemIdentifier(for: indexPath)
|
||||
}
|
||||
|
||||
private(set) var itemLastSelectedTimestamps = [Item: Date]()
|
||||
|
||||
init(mastodonController: MastodonController) {
|
||||
self.mastodonController = mastodonController
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
navigationItem.title = "Tusker"
|
||||
navigationItem.largeTitleDisplayMode = .always
|
||||
navigationController!.navigationBar.prefersLargeTitles = true
|
||||
|
||||
let layout = UICollectionViewCompositionalLayout.list(using: .init(appearance: .sidebar))
|
||||
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
|
||||
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
collectionView.backgroundColor = .systemGroupedBackground
|
||||
collectionView.delegate = self
|
||||
view.addSubview(collectionView)
|
||||
|
||||
dataSource = createDataSource()
|
||||
|
||||
applyInitialSnapshot()
|
||||
|
||||
select(item: .tab(.timelines), animated: false)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedHashtags), name: .savedHashtagsChanged, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil)
|
||||
}
|
||||
|
||||
func select(item: Item, animated: Bool) {
|
||||
guard let indexPath = dataSource.indexPath(for: item) else { return }
|
||||
collectionView.selectItem(at: indexPath, animated: animated, scrollPosition: .top)
|
||||
itemLastSelectedTimestamps[item] = Date()
|
||||
}
|
||||
|
||||
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||
let listCell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { (cell, indexPath, item) in
|
||||
var config = cell.defaultContentConfiguration()
|
||||
config.text = item.title
|
||||
if let imageName = item.imageName {
|
||||
config.image = UIImage(systemName: imageName)
|
||||
}
|
||||
cell.contentConfiguration = config
|
||||
}
|
||||
|
||||
let outlineHeaderCell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { (cell, indexPath, item) in
|
||||
var config = cell.defaultContentConfiguration()
|
||||
config.attributedText = NSAttributedString(string: item.title, attributes: [
|
||||
.font: UIFont.boldSystemFont(ofSize: 21)
|
||||
])
|
||||
cell.contentConfiguration = config
|
||||
cell.accessories = [.outlineDisclosure(options: .init(style: .header))]
|
||||
}
|
||||
|
||||
return UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView, cellProvider: { (collectionView, indexPath, item) -> UICollectionViewCell? in
|
||||
if item.hasChildren {
|
||||
return collectionView.dequeueConfiguredReusableCell(using: outlineHeaderCell, for: indexPath, item: item)
|
||||
} else {
|
||||
return collectionView.dequeueConfiguredReusableCell(using: listCell, for: indexPath, item: item)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func applyInitialSnapshot() {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections(Section.allCases)
|
||||
snapshot.appendItems([
|
||||
.tab(.timelines),
|
||||
.tab(.notifications),
|
||||
.search,
|
||||
.bookmarks,
|
||||
.tab(.myProfile)
|
||||
], toSection: .tabs)
|
||||
snapshot.appendItems([
|
||||
.tab(.compose)
|
||||
], toSection: .compose)
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
|
||||
reloadLists()
|
||||
reloadSavedHashtags()
|
||||
reloadSavedInstances()
|
||||
}
|
||||
|
||||
private func reloadLists() {
|
||||
let request = Client.getLists()
|
||||
mastodonController.run(request) { [weak self] (response) in
|
||||
guard let self = self, case let .success(lists, _) = response else { return }
|
||||
|
||||
var exploreSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
|
||||
exploreSnapshot.append([.listsHeader])
|
||||
exploreSnapshot.expand([.listsHeader])
|
||||
exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader)
|
||||
exploreSnapshot.append([.addList], to: .listsHeader)
|
||||
DispatchQueue.main.async {
|
||||
self.dataSource.apply(exploreSnapshot, to: .lists)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func reloadSavedHashtags() {
|
||||
var hashtagsSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
|
||||
hashtagsSnapshot.append([.savedHashtagsHeader])
|
||||
hashtagsSnapshot.expand([.savedHashtagsHeader])
|
||||
let sortedHashtags = SavedDataManager.shared.sortedHashtags(for: mastodonController.accountInfo!)
|
||||
hashtagsSnapshot.append(sortedHashtags.map { .savedHashtag($0) }, to: .savedHashtagsHeader)
|
||||
hashtagsSnapshot.append([.addSavedHashtag], to: .savedHashtagsHeader)
|
||||
self.dataSource.apply(hashtagsSnapshot, to: .savedHashtags, animatingDifferences: false)
|
||||
}
|
||||
|
||||
@objc private func reloadSavedInstances() {
|
||||
var instancesSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
|
||||
instancesSnapshot.append([.savedInstancesHeader])
|
||||
instancesSnapshot.expand([.savedInstancesHeader])
|
||||
let sortedInstances = SavedDataManager.shared.savedInstances(for: mastodonController.accountInfo!)
|
||||
instancesSnapshot.append(sortedInstances.map { .savedInstance($0) }, to: .savedInstancesHeader)
|
||||
instancesSnapshot.append([.addSavedInstance], to: .savedInstancesHeader)
|
||||
self.dataSource.apply(instancesSnapshot, to: .savedInstances, animatingDifferences: false)
|
||||
}
|
||||
|
||||
// todo: deduplicate with ExploreViewController
|
||||
private func showAddList() {
|
||||
let alert = UIAlertController(title: "New List", message: "Choose a title for your new list", preferredStyle: .alert)
|
||||
alert.addTextField(configurationHandler: nil)
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
|
||||
alert.addAction(UIAlertAction(title: "Create List", style: .default, handler: { (_) in
|
||||
guard let title = alert.textFields?.first?.text else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
let request = Client.createList(title: title)
|
||||
self.mastodonController.run(request) { (response) in
|
||||
guard case let .success(list, _) = response else { fatalError() }
|
||||
|
||||
self.reloadLists()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.sidebarDelegate?.sidebar(self, didSelectItem: .list(list))
|
||||
}
|
||||
}
|
||||
}))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
// todo: deduplicate with ExploreViewController
|
||||
private func showAddSavedHashtag() {
|
||||
let navController = EnhancedNavigationViewController(rootViewController: AddSavedHashtagViewController(mastodonController: mastodonController))
|
||||
present(navController, animated: true)
|
||||
}
|
||||
|
||||
// todo: deduplicate with ExploreViewController
|
||||
private func showAddSavedInstance() {
|
||||
let findController = FindInstanceViewController(parentMastodonController: mastodonController)
|
||||
findController.instanceTimelineDelegate = self
|
||||
let navController = EnhancedNavigationViewController(rootViewController: findController)
|
||||
present(navController, animated: true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
extension MainSidebarViewController {
|
||||
enum Section: Int, Hashable, CaseIterable {
|
||||
case tabs
|
||||
case compose
|
||||
case lists
|
||||
case savedHashtags
|
||||
case savedInstances
|
||||
}
|
||||
enum Item: Hashable {
|
||||
case tab(MainTabBarViewController.Tab)
|
||||
case search, bookmarks
|
||||
case listsHeader, list(List), addList
|
||||
case savedHashtagsHeader, savedHashtag(Hashtag), addSavedHashtag
|
||||
case savedInstancesHeader, savedInstance(URL), addSavedInstance
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case let .tab(tab):
|
||||
return tab.title
|
||||
case .search:
|
||||
return "Search"
|
||||
case .bookmarks:
|
||||
return "Bookmarks"
|
||||
case .listsHeader:
|
||||
return "Lists"
|
||||
case let .list(list):
|
||||
return list.title
|
||||
case .addList:
|
||||
return "New List..."
|
||||
case .savedHashtagsHeader:
|
||||
return "Saved Hashtags"
|
||||
case let .savedHashtag(hashtag):
|
||||
return hashtag.name
|
||||
case .addSavedHashtag:
|
||||
return "Save Hashtag..."
|
||||
case .savedInstancesHeader:
|
||||
return "Saved Instances"
|
||||
case let .savedInstance(url):
|
||||
return url.host!
|
||||
case .addSavedInstance:
|
||||
return "Find An Instance..."
|
||||
}
|
||||
}
|
||||
|
||||
var imageName: String? {
|
||||
switch self {
|
||||
case let .tab(tab):
|
||||
return tab.imageName
|
||||
case .search:
|
||||
return "magnifyingglass"
|
||||
case .bookmarks:
|
||||
return "bookmark"
|
||||
case .list(_):
|
||||
return "list.bullet"
|
||||
case .savedHashtag(_):
|
||||
return "number"
|
||||
case .savedInstance(_):
|
||||
return "globe"
|
||||
case .listsHeader, .savedHashtagsHeader, .savedInstancesHeader:
|
||||
return nil
|
||||
case .addList, .addSavedHashtag, .addSavedInstance:
|
||||
return "plus"
|
||||
}
|
||||
}
|
||||
|
||||
var hasChildren: Bool {
|
||||
switch self {
|
||||
case .listsHeader, .savedHashtagsHeader, .savedInstancesHeader:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension MainTabBarViewController.Tab {
|
||||
var title: String {
|
||||
switch self {
|
||||
case .timelines:
|
||||
return "Home"
|
||||
case .notifications:
|
||||
return "Notifications"
|
||||
case .compose:
|
||||
return "Compose"
|
||||
case .explore:
|
||||
return "Explore"
|
||||
case .myProfile:
|
||||
return "My Profile"
|
||||
}
|
||||
}
|
||||
|
||||
var imageName: String? {
|
||||
switch self {
|
||||
case .timelines:
|
||||
return "house"
|
||||
case .notifications:
|
||||
return "bell"
|
||||
case .compose:
|
||||
return "pencil"
|
||||
case .explore:
|
||||
return "magnifyingglass"
|
||||
case .myProfile:
|
||||
// todo: use user avatar image
|
||||
return "person"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
extension MainSidebarViewController: UICollectionViewDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
|
||||
previouslySelectedItem = selectedItem
|
||||
return true
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else {
|
||||
collectionView.deselectItem(at: indexPath, animated: true)
|
||||
return
|
||||
}
|
||||
itemLastSelectedTimestamps[item] = Date()
|
||||
if [MainSidebarViewController.Item.tab(.compose), .addList, .addSavedHashtag, .addSavedInstance].contains(item) {
|
||||
if let previous = previouslySelectedItem, let indexPath = dataSource.indexPath(for: previous) {
|
||||
collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredVertically)
|
||||
}
|
||||
switch item {
|
||||
case .tab(.compose):
|
||||
sidebarDelegate?.sidebarRequestPresentCompose(self)
|
||||
case .addList:
|
||||
showAddList()
|
||||
case .addSavedHashtag:
|
||||
showAddSavedHashtag()
|
||||
case .addSavedInstance:
|
||||
showAddSavedInstance()
|
||||
default:
|
||||
fatalError("unreachable")
|
||||
}
|
||||
} else {
|
||||
sidebarDelegate?.sidebar(self, didSelectItem: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
extension MainSidebarViewController: InstanceTimelineViewControllerDelegate {
|
||||
func didSaveInstance(url: URL) {
|
||||
dismiss(animated: true) {
|
||||
self.sidebarDelegate?.sidebar(self, didSelectItem: .savedInstance(url))
|
||||
}
|
||||
}
|
||||
|
||||
func didUnsaveInstance(url: URL) {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
#endif
|
|
@ -0,0 +1,326 @@
|
|||
//
|
||||
// MainSplitViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 6/23/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
#if SDK_IOS_14
|
||||
@available(iOS 14.0, *)
|
||||
class MainSplitViewController: UISplitViewController {
|
||||
|
||||
weak var mastodonController: MastodonController!
|
||||
|
||||
private var sidebar: MainSidebarViewController!
|
||||
|
||||
// Keep track of navigation stacks per-item so that we can only ever use a single navigation controller
|
||||
private var navigationStacks: [MainSidebarViewController.Item: [UIViewController]] = [:]
|
||||
|
||||
private var tabBarViewController: MainTabBarViewController!
|
||||
|
||||
init(mastodonController: MastodonController) {
|
||||
self.mastodonController = mastodonController
|
||||
|
||||
super.init(style: .doubleColumn)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
preferredDisplayMode = .oneBesideSecondary
|
||||
preferredSplitBehavior = .tile
|
||||
presentsWithGesture = false
|
||||
showsSecondaryOnlyButton = false
|
||||
delegate = self
|
||||
|
||||
sidebar = MainSidebarViewController(mastodonController: mastodonController)
|
||||
sidebar.sidebarDelegate = self
|
||||
setViewController(sidebar, for: .primary)
|
||||
|
||||
setViewController(EnhancedNavigationViewController(), for: .secondary)
|
||||
select(item: .tab(.timelines))
|
||||
|
||||
tabBarViewController = MainTabBarViewController(mastodonController: mastodonController)
|
||||
setViewController(tabBarViewController, for: .compact)
|
||||
}
|
||||
|
||||
func select(item: MainSidebarViewController.Item) {
|
||||
let nav = viewController(for: .secondary) as! UINavigationController
|
||||
nav.viewControllers = getOrCreateNavigationStack(item: item)
|
||||
}
|
||||
|
||||
func getOrCreateNavigationStack(item: MainSidebarViewController.Item) -> [UIViewController] {
|
||||
if let existing = navigationStacks[item], existing.count > 0 {
|
||||
return existing
|
||||
} else {
|
||||
let new = [item.createRootViewController(mastodonController)!]
|
||||
navigationStacks[item] = new
|
||||
return new
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
extension MainSplitViewController: UISplitViewControllerDelegate {
|
||||
/// Transfer the navigation stack for a sidebar item to a destination navgiation controller.
|
||||
/// - Parameter dropFirst: Remove the first view controller from the item's navigation stack before transferring.
|
||||
/// - Parameter append: Append the item's navigation stack to the destination nav controller's instead of replacing it.
|
||||
private func transferNavigationStack(from item: MainSidebarViewController.Item, to destination: UINavigationController, dropFirst: Bool = false, append: Bool = false) {
|
||||
var itemNavStack: [UIViewController]
|
||||
if item == sidebar.selectedItem {
|
||||
let detailNav = viewController(for: .secondary) as! UINavigationController
|
||||
itemNavStack = detailNav.viewControllers
|
||||
} else {
|
||||
itemNavStack = navigationStacks[item] ?? []
|
||||
navigationStacks.removeValue(forKey: item)
|
||||
}
|
||||
if itemNavStack.isEmpty {
|
||||
itemNavStack = [item.createRootViewController(mastodonController)!]
|
||||
}
|
||||
|
||||
if dropFirst {
|
||||
itemNavStack.remove(at: 0)
|
||||
}
|
||||
|
||||
if append {
|
||||
destination.viewControllers += itemNavStack
|
||||
} else {
|
||||
destination.viewControllers = itemNavStack
|
||||
}
|
||||
}
|
||||
|
||||
func splitViewControllerDidCollapse(_ svc: UISplitViewController) {
|
||||
// on iPhones, the sidebar VC is never loaded, but since this method is still called, we can't do anything
|
||||
guard sidebar.isViewLoaded else { return }
|
||||
|
||||
// Transfer the nav stacks for all the sidebar items that map 1 <-> 1 with tabs
|
||||
for tab in [MainTabBarViewController.Tab.timelines, .notifications, .myProfile] {
|
||||
let tabNav = tabBarViewController.viewController(for: tab) as! UINavigationController
|
||||
transferNavigationStack(from: .tab(tab), to: tabNav)
|
||||
}
|
||||
|
||||
// Since several sidebar items map to the single Explore tab, we only transfer the
|
||||
// navigation stack of the most-recently used one.
|
||||
let mostRecentExploreItem: (MainSidebarViewController.Item, Date)? =
|
||||
sidebar.exploreTabItems.compactMap {
|
||||
if let timestamp = sidebar.itemLastSelectedTimestamps[$0] {
|
||||
return ($0, timestamp)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}.min {
|
||||
$0.1 > $1.1
|
||||
}
|
||||
if let mostRecentExploreItem = mostRecentExploreItem?.0,
|
||||
mostRecentExploreItem != .search {
|
||||
let exploreNav = tabBarViewController.viewController(for: .explore) as! UINavigationController
|
||||
// Pop back to root, so we're appending to the Explore VC instead of some other VC
|
||||
exploreNav.popToRootViewController(animated: false)
|
||||
// Append so we don't replace the Explore VC
|
||||
transferNavigationStack(from: mostRecentExploreItem, to: exploreNav, append: true)
|
||||
}
|
||||
|
||||
// Switch the tab bar to focus the same item as the sidebar has selected
|
||||
switch sidebar.selectedItem! {
|
||||
case let .tab(tab):
|
||||
// sidebar items that map 1 <-> 1 can be transferred directly
|
||||
tabBarViewController.select(tab: tab)
|
||||
|
||||
case .search:
|
||||
// Search sidebar item maps to the Explore tab with the search controller/results visible
|
||||
// The nav stack can't be copied directly, since the split VC uses a different SearchViewController
|
||||
// so that explore items aren't shown multiple times.
|
||||
|
||||
let exploreNav = tabBarViewController.viewController(for: .explore) as! UINavigationController
|
||||
let explore: ExploreViewController
|
||||
if let existing = exploreNav.viewControllers.first as? ExploreViewController {
|
||||
explore = existing
|
||||
exploreNav.popToRootViewController(animated: false)
|
||||
} else {
|
||||
// If the Explore tab hasn't been loaded before, it's root view controller won't be loaded yet, so create and add it manually.
|
||||
explore = ExploreViewController(mastodonController: mastodonController)
|
||||
exploreNav.viewControllers = [explore]
|
||||
}
|
||||
// Make sure viewDidLoad is called so that the searchController/resultsController have been initialized
|
||||
explore.loadViewIfNeeded()
|
||||
|
||||
let nav = viewController(for: .secondary) as! UINavigationController
|
||||
let search = nav.viewControllers.first as! SearchViewController
|
||||
// Copy the search query from the search VC to the Explore VC's search controller.
|
||||
let query = search.searchController.searchBar.text ?? ""
|
||||
explore.searchController.searchBar.text = query
|
||||
// Instruct the explore controller to show its search controller immediately upon its first appearance.
|
||||
// explore.searchController.isActive can't be set directly, see FB7814561
|
||||
explore.searchControllerStatusOnAppearance = !query.isEmpty
|
||||
// Copy the results from the search VC's results controller to avoid the delay introduced by an extra network request
|
||||
explore.resultsController.loadResults(from: search.resultsController)
|
||||
|
||||
// Transfer the navigation stack, dropping the search VC, to keep anything the user has opened
|
||||
transferNavigationStack(from: .search, to: exploreNav, dropFirst: true, append: true)
|
||||
|
||||
tabBarViewController.select(tab: .explore)
|
||||
|
||||
case .bookmarks, .list(_), .savedHashtag(_), .savedInstance(_):
|
||||
tabBarViewController.select(tab: .explore)
|
||||
// Make sure the Explore VC doesn't show it's search bar when it appears, in case the user was previously
|
||||
// in compact mode and performing a search.
|
||||
let exploreNav = tabBarViewController.viewController(for: .explore) as! UINavigationController
|
||||
let explore = exploreNav.viewControllers.first as! ExploreViewController
|
||||
explore.searchControllerStatusOnAppearance = false
|
||||
|
||||
case .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance:
|
||||
// These items are not selectable in the sidebar collection view, so this code is unreachable.
|
||||
fatalError("unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
/// Transfer a navigation stack from a navigation controller belonging to the tab bar VC to a sidebar item.
|
||||
/// - Parameter skipFirst:The number of view controllers that should be skipped from the source navigation controller.
|
||||
/// - Parameter prepend: An optional view controller to prepend to the beginning of the navigation stack being moved.
|
||||
private func transferNavigationStack(from navController: UINavigationController, to item: MainSidebarViewController.Item, skipFirst: Int = 0, prepend: UIViewController? = nil) {
|
||||
let viewControllersToMove = navController.viewControllers.dropFirst(skipFirst)
|
||||
navController.viewControllers.removeLast(navController.viewControllers.count - skipFirst)
|
||||
|
||||
if let prepend = prepend {
|
||||
navigationStacks[item] = [prepend] + viewControllersToMove
|
||||
} else {
|
||||
navigationStacks[item] = Array(viewControllersToMove)
|
||||
}
|
||||
}
|
||||
|
||||
func splitViewControllerDidExpand(_ svc: UISplitViewController) {
|
||||
// For each sidebar item, transfer the existing navigation stasck from the tab bar controller to ourself.
|
||||
var exploreItem: MainSidebarViewController.Item?
|
||||
for tab in MainTabBarViewController.Tab.allCases {
|
||||
guard let tabNavController = tabBarViewController.viewController(for: tab) as? UINavigationController else { continue }
|
||||
let tabNavigationStack = tabNavController.viewControllers
|
||||
|
||||
switch tab {
|
||||
case .timelines, .notifications, .myProfile:
|
||||
// Items that map 1 <-> 1 to tabs can be transferred directly.
|
||||
let item = MainSidebarViewController.Item.tab(tab)
|
||||
transferNavigationStack(from: tabNavController, to: item)
|
||||
|
||||
case .explore:
|
||||
// The Explore tab is more complicated since it encapsulates a bunch of screens which have top-level sidebar items.
|
||||
|
||||
var toPrepend: UIViewController? = nil
|
||||
|
||||
// If the tab navigation stack has only one item or the search controller is active, it corresponds to the Search item
|
||||
// For other items, the 2nd VC in the nav stack determines which sidebar item they map to.
|
||||
// Search screen has special considerations, all others can be transferred directly.
|
||||
if tabNavigationStack.count == 1 || ((tabNavigationStack.first as? ExploreViewController)?.searchController?.isActive ?? false) {
|
||||
exploreItem = .search
|
||||
let searchVC = SearchViewController(mastodonController: mastodonController)
|
||||
searchVC.loadViewIfNeeded()
|
||||
let explore = tabNavigationStack.first as! ExploreViewController
|
||||
if let exploreSearchControler = explore.searchController,
|
||||
let query = exploreSearchControler.searchBar.text {
|
||||
// Transfer query to search VC
|
||||
searchVC.searchController.searchBar.text = query
|
||||
// If there is a query, make the search VC activate itself upon appearing
|
||||
searchVC.searchControllerStatusOnAppearance = !query.isEmpty
|
||||
// Transfer the results from the explore VC, to avoid an extra network request
|
||||
searchVC.resultsController.loadResults(from: explore.resultsController)
|
||||
}
|
||||
// Insert the new search VC at the beginning of the new search nav stack
|
||||
toPrepend = searchVC
|
||||
} else if tabNavigationStack[1] is BookmarksTableViewController {
|
||||
exploreItem = .bookmarks
|
||||
} else if let listVC = tabNavigationStack[1] as? ListTimelineViewController {
|
||||
exploreItem = .list(listVC.list)
|
||||
} else if let hashtagVC = tabNavigationStack[1] as? HashtagTimelineViewController {
|
||||
exploreItem = .savedHashtag(hashtagVC.hashtag)
|
||||
} else if let instanceVC = tabNavigationStack[1] as? InstanceTimelineViewController {
|
||||
exploreItem = .savedInstance(instanceVC.instanceURL)
|
||||
}
|
||||
transferNavigationStack(from: tabNavController, to: exploreItem!, skipFirst: 1, prepend: toPrepend)
|
||||
|
||||
case .compose:
|
||||
// The compose tab can't be activated, this is unreachable.
|
||||
fatalError("unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
// Transfer the selected tab from the tab bar VC to the sidebar
|
||||
switch tabBarViewController.selectedTab {
|
||||
case .timelines, .notifications, .myProfile:
|
||||
// These tabs map 1 <-> 1 with sidebar items
|
||||
let item = MainSidebarViewController.Item.tab(tabBarViewController.selectedTab)
|
||||
sidebar.select(item: item, animated: false)
|
||||
select(item: item)
|
||||
|
||||
case .explore:
|
||||
// If the explore tab is active, the sidebar item is determined above when transferring the explore VC's nav stack
|
||||
sidebar.select(item: exploreItem!, animated: false)
|
||||
select(item: exploreItem!)
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
extension MainSplitViewController: MainSidebarViewControllerDelegate {
|
||||
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController) {
|
||||
presentCompose()
|
||||
}
|
||||
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) {
|
||||
let nav = viewController(for: .secondary) as! UINavigationController
|
||||
if let previous = sidebar.previouslySelectedItem {
|
||||
navigationStacks[previous] = nav.viewControllers
|
||||
}
|
||||
select(item: item)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
fileprivate extension MainSidebarViewController.Item {
|
||||
func createRootViewController(_ mastodonController: MastodonController) -> UIViewController? {
|
||||
switch self {
|
||||
case let .tab(tab):
|
||||
return tab.createViewController(mastodonController)
|
||||
case .search:
|
||||
return SearchViewController(mastodonController: mastodonController)
|
||||
case .bookmarks:
|
||||
return BookmarksTableViewController(mastodonController: mastodonController)
|
||||
case let .list(list):
|
||||
return ListTimelineViewController(for: list, mastodonController: mastodonController)
|
||||
case let .savedHashtag(hashtag):
|
||||
return HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController)
|
||||
case let .savedInstance(url):
|
||||
return InstanceTimelineViewController(for: url, parentMastodonController: mastodonController)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
extension MainSplitViewController: TuskerRootViewController {
|
||||
func presentCompose() {
|
||||
let vc = ComposeHostingController(mastodonController: mastodonController)
|
||||
let nav = EnhancedNavigationViewController(rootViewController: vc)
|
||||
nav.presentationController?.delegate = vc
|
||||
present(nav, animated: true)
|
||||
}
|
||||
|
||||
func select(tab: MainTabBarViewController.Tab) {
|
||||
if tab == .compose {
|
||||
presentCompose()
|
||||
} else {
|
||||
select(item: .tab(tab))
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
|
@ -7,11 +7,18 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
|
||||
|
||||
weak var mastodonController: MastodonController!
|
||||
|
||||
private var composePlaceholder: UIViewController!
|
||||
|
||||
var selectedTab: Tab {
|
||||
return Tab(rawValue: selectedIndex)!
|
||||
}
|
||||
|
||||
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
if UIDevice.current.userInterfaceIdiom == .phone {
|
||||
return .portrait
|
||||
|
@ -35,12 +42,16 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
|
|||
|
||||
self.delegate = self
|
||||
|
||||
composePlaceholder = UIViewController()
|
||||
composePlaceholder.title = "Compose"
|
||||
composePlaceholder.tabBarItem.image = UIImage(systemName: "pencil")
|
||||
|
||||
viewControllers = [
|
||||
embedInNavigationController(TimelinesPageViewController(mastodonController: mastodonController)),
|
||||
embedInNavigationController(NotificationsPageViewController(mastodonController: mastodonController)),
|
||||
ComposeViewController(mastodonController: mastodonController),
|
||||
embedInNavigationController(ExploreViewController(mastodonController: mastodonController)),
|
||||
embedInNavigationController(MyProfileTableViewController(mastodonController: mastodonController)),
|
||||
embedInNavigationController(Tab.timelines.createViewController(mastodonController)),
|
||||
embedInNavigationController(Tab.notifications.createViewController(mastodonController)),
|
||||
composePlaceholder,
|
||||
embedInNavigationController(Tab.explore.createViewController(mastodonController)),
|
||||
embedInNavigationController(Tab.myProfile.createViewController(mastodonController)),
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -53,35 +64,50 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
|
|||
}
|
||||
|
||||
func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
|
||||
if viewController is ComposeViewController {
|
||||
if viewController == composePlaceholder {
|
||||
presentCompose()
|
||||
return false
|
||||
}
|
||||
if viewController == viewControllers![selectedIndex],
|
||||
let nav = viewController as? UINavigationController,
|
||||
nav.viewControllers.count == 1,
|
||||
let scrollableVC = nav.viewControllers.first as? TabBarScrollableViewController {
|
||||
scrollableVC.tabBarScrollToTop()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func presentCompose() {
|
||||
let compose = ComposeViewController(mastodonController: mastodonController)
|
||||
let navigationController = embedInNavigationController(compose)
|
||||
navigationController.presentationController?.delegate = compose
|
||||
present(navigationController, animated: true)
|
||||
func setViewController(_ viewController: UIViewController, forTab tab: Tab) {
|
||||
viewControllers![tab.rawValue] = viewController
|
||||
}
|
||||
|
||||
func viewController(for tab: Tab) -> UIViewController {
|
||||
return viewControllers![tab.rawValue]
|
||||
}
|
||||
}
|
||||
|
||||
extension MainTabBarViewController {
|
||||
enum Tab: Int {
|
||||
enum Tab: Int, Hashable, CaseIterable {
|
||||
case timelines
|
||||
case notifications
|
||||
case compose
|
||||
case explore
|
||||
case myProfile
|
||||
}
|
||||
|
||||
func select(tab: Tab) {
|
||||
if tab == .compose {
|
||||
presentCompose()
|
||||
} else {
|
||||
selectedIndex = tab.rawValue
|
||||
func createViewController(_ mastodonController: MastodonController) -> UIViewController {
|
||||
switch self {
|
||||
case .timelines:
|
||||
return TimelinesPageViewController(mastodonController: mastodonController)
|
||||
case .notifications:
|
||||
return NotificationsPageViewController(mastodonController: mastodonController)
|
||||
case .compose:
|
||||
return ComposeHostingController(mastodonController: mastodonController)
|
||||
case .explore:
|
||||
return ExploreViewController(mastodonController: mastodonController)
|
||||
case .myProfile:
|
||||
return MyProfileViewController(mastodonController: mastodonController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -93,3 +119,20 @@ extension MainTabBarViewController {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MainTabBarViewController: TuskerRootViewController {
|
||||
func presentCompose() {
|
||||
let vc = ComposeHostingController(mastodonController: mastodonController)
|
||||
let nav = EnhancedNavigationViewController(rootViewController: vc)
|
||||
nav.presentationController?.delegate = vc
|
||||
present(nav, animated: true)
|
||||
}
|
||||
|
||||
func select(tab: Tab) {
|
||||
if tab == .compose {
|
||||
presentCompose()
|
||||
} else {
|
||||
selectedIndex = tab.rawValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
//
|
||||
// TuskerRootViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 6/24/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
protocol TuskerRootViewController: UIViewController {
|
||||
func presentCompose()
|
||||
func select(tab: MainTabBarViewController.Tab)
|
||||
}
|
|
@ -118,7 +118,9 @@ class InstanceSelectorTableViewController: UITableViewController {
|
|||
let request = Client.getInstance()
|
||||
client.run(request) { (response) in
|
||||
var snapshot = self.dataSource.snapshot()
|
||||
if snapshot.indexOfSection(.selected) != nil {
|
||||
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .selected))
|
||||
}
|
||||
|
||||
if case let .success(instance, _) = response {
|
||||
if !snapshot.sectionIdentifiers.contains(.selected) {
|
||||
|
|
|
@ -48,19 +48,17 @@ extension OnboardingViewController: InstanceSelectorTableViewControllerDelegate
|
|||
let mastodonController = MastodonController(instanceURL: instanceURL)
|
||||
mastodonController.registerApp { (clientID, clientSecret) in
|
||||
|
||||
let callbackURL = "tusker://oauth"
|
||||
|
||||
var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)!
|
||||
components.path = "/oauth/authorize"
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "client_id", value: clientID),
|
||||
URLQueryItem(name: "response_type", value: "code"),
|
||||
URLQueryItem(name: "scope", value: "read write follow"),
|
||||
URLQueryItem(name: "redirect_uri", value: callbackURL)
|
||||
URLQueryItem(name: "redirect_uri", value: "tusker://oauth")
|
||||
]
|
||||
let authorizeURL = components.url!
|
||||
|
||||
self.authenticationSession = ASWebAuthenticationSession(url: authorizeURL, callbackURLScheme: callbackURL) { url, error in
|
||||
self.authenticationSession = ASWebAuthenticationSession(url: authorizeURL, callbackURLScheme: "tusker") { url, error in
|
||||
guard error == nil,
|
||||
let url = url,
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
|
||||
|
@ -84,6 +82,8 @@ extension OnboardingViewController: InstanceSelectorTableViewControllerDelegate
|
|||
}
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
// Prefer ephemeral sessions to make it easier to sign into multiple accounts on the same instance.
|
||||
self.authenticationSession!.prefersEphemeralWebBrowserSession = true
|
||||
self.authenticationSession!.presentationContextProvider = self
|
||||
self.authenticationSession!.start()
|
||||
}
|
||||
|
|
|
@ -16,7 +16,8 @@ struct AdvancedPrefsView : View {
|
|||
formattingSection
|
||||
automationSection
|
||||
cachingSection
|
||||
}.listStyle(GroupedListStyle())
|
||||
}
|
||||
.insetOrGroupedListStyle()
|
||||
.navigationBarTitle(Text("Advanced"))
|
||||
}
|
||||
|
||||
|
@ -36,7 +37,7 @@ struct AdvancedPrefsView : View {
|
|||
}
|
||||
|
||||
var automationSection: some View {
|
||||
Section(header: Text("AUTOMATION")) {
|
||||
Section(header: Text("Automation")) {
|
||||
NavigationLink(destination: SilentActionPrefs()) {
|
||||
Text("Silent Action Permissions")
|
||||
}
|
||||
|
@ -44,14 +45,17 @@ struct AdvancedPrefsView : View {
|
|||
}
|
||||
|
||||
var cachingSection: some View {
|
||||
Section(header: Text("CACHING")) {
|
||||
Section(header: Text("Caching"), footer: Text("Clearing caches will restart the app.")) {
|
||||
Button(action: clearCache) {
|
||||
Text("Clear Cache")
|
||||
Text("Clear Mastodon Cache")
|
||||
}.foregroundColor(.red)
|
||||
Button(action: clearImageCaches) {
|
||||
Text("Clear Image Caches")
|
||||
}.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
func clearCache() {
|
||||
private func clearCache() {
|
||||
for account in LocalData.shared.accounts {
|
||||
let controller = MastodonController.getForAccount(account)
|
||||
let coordinator = controller.persistentContainer.persistentStoreCoordinator
|
||||
|
@ -59,7 +63,22 @@ struct AdvancedPrefsView : View {
|
|||
try! coordinator.destroyPersistentStore(at: store.url!, ofType: store.type, options: store.options)
|
||||
}
|
||||
}
|
||||
MastodonController.resetAll()
|
||||
resetUI()
|
||||
}
|
||||
|
||||
private func clearImageCaches() {
|
||||
[
|
||||
ImageCache.avatars,
|
||||
ImageCache.headers,
|
||||
ImageCache.attachments,
|
||||
ImageCache.emojis,
|
||||
].forEach {
|
||||
try! $0.reset()
|
||||
}
|
||||
resetUI()
|
||||
}
|
||||
|
||||
private func resetUI() {
|
||||
let mostRecent = LocalData.shared.getMostRecentAccount()!
|
||||
NotificationCenter.default.post(name: .activateAccount, object: nil, userInfo: ["account": mostRecent])
|
||||
}
|
||||
|
|
|
@ -30,15 +30,26 @@ struct AppearancePrefsView : View {
|
|||
Text("Light").tag(UIUserInterfaceStyle.light)
|
||||
Text("Dark").tag(UIUserInterfaceStyle.dark)
|
||||
}
|
||||
Toggle(isOn: $preferences.showRepliesInProfiles) {
|
||||
Text("Show Replies in Profiles")
|
||||
accountsSection
|
||||
postsSection
|
||||
}
|
||||
.insetOrGroupedListStyle()
|
||||
.navigationBarTitle(Text("Appearance"))
|
||||
}
|
||||
|
||||
private var accountsSection: some View {
|
||||
Section(header: Text("Accounts")) {
|
||||
Toggle(isOn: useCircularAvatars) {
|
||||
Text("Use Circular Avatars")
|
||||
}
|
||||
Toggle(isOn: $preferences.hideCustomEmojiInUsernames) {
|
||||
Text("Hide Custom Emoji in Usernames")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var postsSection: some View {
|
||||
Section(header: Text("Posts")) {
|
||||
Toggle(isOn: $preferences.showIsStatusReplyIcon) {
|
||||
Text("Show Status Reply Icons")
|
||||
}
|
||||
|
@ -46,8 +57,6 @@ struct AppearancePrefsView : View {
|
|||
Text("Always Show Status Visibility Icons")
|
||||
}
|
||||
}
|
||||
.listStyle(GroupedListStyle())
|
||||
.navigationBarTitle(Text("Appearance"))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,11 +14,14 @@ struct BehaviorPrefsView: View {
|
|||
var body: some View {
|
||||
List {
|
||||
linksSection
|
||||
}.listStyle(GroupedListStyle()).navigationBarTitle(Text("Behavior"))
|
||||
contentWarningsSection
|
||||
}
|
||||
.insetOrGroupedListStyle()
|
||||
.navigationBarTitle(Text("Behavior"))
|
||||
}
|
||||
|
||||
var linksSection: some View {
|
||||
Section(header: Text("LINKS")) {
|
||||
Section(header: Text("Links")) {
|
||||
Toggle(isOn: $preferences.openLinksInApps) {
|
||||
Text("Open Links in Apps")
|
||||
}
|
||||
|
@ -30,6 +33,18 @@ struct BehaviorPrefsView: View {
|
|||
}.disabled(!preferences.useInAppSafari)
|
||||
}
|
||||
}
|
||||
|
||||
var contentWarningsSection: some View {
|
||||
Section(header: Text("Content Warnings")) {
|
||||
Toggle(isOn: $preferences.expandAllContentWarnings) {
|
||||
Text("Expand All Content Warnings")
|
||||
}
|
||||
|
||||
Toggle(isOn: $preferences.collapseLongPosts) {
|
||||
Text("Collapse Long Posts")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
|
|
@ -16,11 +16,13 @@ struct ComposingPrefsView: View {
|
|||
List {
|
||||
composingSection
|
||||
replyingSection
|
||||
}.listStyle(GroupedListStyle()).navigationBarTitle("Composing")
|
||||
}
|
||||
.insetOrGroupedListStyle()
|
||||
.navigationBarTitle("Composing")
|
||||
}
|
||||
|
||||
var composingSection: some View {
|
||||
Section(header: Text("COMPOSING")) {
|
||||
Section(header: Text("Composing")) {
|
||||
Picker(selection: $preferences.defaultPostVisibility, label: Text("Default Post Visibility")) {
|
||||
ForEach(Status.Visibility.allCases, id: \.self) { visibility in
|
||||
HStack {
|
||||
|
@ -41,7 +43,7 @@ struct ComposingPrefsView: View {
|
|||
}
|
||||
|
||||
var replyingSection: some View {
|
||||
Section(header: Text("REPLYING")) {
|
||||
Section(header: Text("Replying")) {
|
||||
Picker(selection: $preferences.contentWarningCopyMode, label: Text("Copy Content Warnings")) {
|
||||
Text("As-is").tag(ContentWarningCopyMode.asIs)
|
||||
Text("Prepend 're: '").tag(ContentWarningCopyMode.prependRe)
|
||||
|
|
|
@ -14,11 +14,13 @@ struct MediaPrefsView: View {
|
|||
var body: some View {
|
||||
List {
|
||||
viewingSection
|
||||
}.listStyle(GroupedListStyle()).navigationBarTitle("Media")
|
||||
}
|
||||
.insetOrGroupedListStyle()
|
||||
.navigationBarTitle("Media")
|
||||
}
|
||||
|
||||
var viewingSection: some View {
|
||||
Section(header: Text("VIEWING")) {
|
||||
Section(header: Text("Viewing")) {
|
||||
Toggle(isOn: $preferences.blurAllMedia) {
|
||||
Text("Blur All Media")
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@ struct PreferencesView: View {
|
|||
// workaround: the navigation view is provided by MyProfileTableViewController so that it can inject the Done button
|
||||
// NavigationView {
|
||||
List {
|
||||
Section {
|
||||
Section(header: Text("Accounts")) {
|
||||
ForEach(localData.accounts, id: \.accessToken) { (account) in
|
||||
Button(action: {
|
||||
NotificationCenter.default.post(name: .activateAccount, object: nil, userInfo: ["account": account])
|
||||
|
@ -75,12 +75,8 @@ struct PreferencesView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.listStyle(GroupedListStyle())
|
||||
.insetOrGroupedListStyle()
|
||||
.navigationBarTitle(Text("Preferences"), displayMode: .inline)
|
||||
.onDisappear {
|
||||
// todo: this onDisappear callback is not called in beta 4, check again in beta 5
|
||||
NotificationCenter.default.post(name: .preferencesChanged, object: nil)
|
||||
}
|
||||
// }
|
||||
}
|
||||
|
||||
|
@ -89,6 +85,17 @@ struct PreferencesView: View {
|
|||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func insetOrGroupedListStyle() -> some View {
|
||||
if #available(iOS 14.0, *) {
|
||||
self.listStyle(InsetGroupedListStyle())
|
||||
} else {
|
||||
self.listStyle(GroupedListStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct PreferencesView_Previews : PreviewProvider {
|
||||
static var previews: some View {
|
||||
|
|
|
@ -14,7 +14,7 @@ struct SilentActionPrefs : View {
|
|||
List(Array(preferences.silentActions.keys), id: \.self) { source in
|
||||
SilentActionPermissionCell(source: source)
|
||||
}
|
||||
.listStyle(GroupedListStyle())
|
||||
.insetOrGroupedListStyle()
|
||||
// .navigationBarTitle("Silent Action Permissions")
|
||||
// see FB6838291
|
||||
}
|
||||
|
|
|
@ -15,7 +15,8 @@ struct WellnessPrefsView: View {
|
|||
List {
|
||||
showFavAndReblogCountSection
|
||||
notificationsModeSection
|
||||
}.listStyle(GroupedListStyle())
|
||||
}
|
||||
.insetOrGroupedListStyle()
|
||||
.navigationBarTitle(Text("Digital Wellness"))
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// MyProfileTableViewController.swift
|
||||
// MyProfileViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 11/24/18.
|
||||
|
@ -7,9 +7,8 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
class MyProfileTableViewController: ProfileTableViewController {
|
||||
class MyProfileViewController: ProfileViewController {
|
||||
|
||||
init(mastodonController: MastodonController) {
|
||||
super.init(accountID: nil, mastodonController: mastodonController)
|
||||
|
@ -17,9 +16,10 @@ class MyProfileTableViewController: ProfileTableViewController {
|
|||
title = "My Profile"
|
||||
tabBarItem.image = UIImage(systemName: "person.fill")
|
||||
|
||||
|
||||
mastodonController.getOwnAccount { (account) in
|
||||
DispatchQueue.main.async {
|
||||
self.accountID = account.id
|
||||
}
|
||||
|
||||
_ = ImageCache.avatars.get(account.avatar, completion: { [weak self] (data) in
|
||||
guard let self = self, let data = data, let image = UIImage(data: data) else { return }
|
||||
|
@ -33,28 +33,20 @@ class MyProfileTableViewController: ProfileTableViewController {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Preferences", style: .plain, target: self, action: #selector(preferencesPressed))
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Preferences", style: .plain, target: self, action: #selector(preferencesPressed))
|
||||
}
|
||||
|
||||
@objc func preferencesPressed() {
|
||||
present(PreferencesNavigationController(mastodonController: mastodonController), animated: true)
|
||||
}
|
||||
|
||||
@objc func closePreferences() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,321 @@
|
|||
//
|
||||
// ProfileStatusesViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 7/3/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
class ProfileStatusesViewController: EnhancedTableViewController {
|
||||
|
||||
weak var mastodonController: MastodonController!
|
||||
|
||||
private(set) var headerView: ProfileHeaderView!
|
||||
|
||||
var accountID: String!
|
||||
|
||||
let kind: Kind
|
||||
|
||||
private var pinnedStatuses: [(id: String, state: StatusState)] = []
|
||||
private var timelineSegments: [[(id: String, state: StatusState)]] = []
|
||||
|
||||
private var older: RequestRange?
|
||||
private var newer: RequestRange?
|
||||
|
||||
var loaded = false
|
||||
|
||||
init(accountID: String?, kind: Kind, mastodonController: MastodonController) {
|
||||
self.accountID = accountID
|
||||
self.kind = kind
|
||||
self.mastodonController = mastodonController
|
||||
|
||||
super.init(style: .plain)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
refreshControl = UIRefreshControl()
|
||||
refreshControl!.addTarget(self, action: #selector(refreshStatuses(_:)), for: .valueChanged)
|
||||
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 140
|
||||
|
||||
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: "statusCell")
|
||||
|
||||
tableView.prefetchDataSource = self
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
if !loaded,
|
||||
let accountID = accountID,
|
||||
let account = mastodonController.persistentContainer.account(for: accountID) {
|
||||
updateUI(account: account)
|
||||
}
|
||||
}
|
||||
|
||||
func updateUI(account: AccountMO) {
|
||||
guard !loaded else { return }
|
||||
loaded = true
|
||||
|
||||
if kind == .statuses {
|
||||
getPinnedStatuses { (response) in
|
||||
guard case let .success(statuses, _) = response else {
|
||||
// todo: error message
|
||||
return
|
||||
}
|
||||
if statuses.isEmpty { return }
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
self.pinnedStatuses = statuses.map { ($0.id, .unknown) }
|
||||
let indexPaths = (0..<statuses.count).map { IndexPath(row: $0, section: 0) }
|
||||
DispatchQueue.main.async {
|
||||
UIView.performWithoutAnimation {
|
||||
self.tableView.insertRows(at: indexPaths, with: .none)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getStatuses { (response) in
|
||||
guard case let .success(statuses, pagination) = response else {
|
||||
// todo: error message
|
||||
return
|
||||
}
|
||||
if statuses.isEmpty { return }
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
self.timelineSegments.append(statuses.map { ($0.id, .unknown) })
|
||||
|
||||
self.older = pagination?.older
|
||||
self.newer = pagination?.newer
|
||||
|
||||
DispatchQueue.main.async {
|
||||
UIView.performWithoutAnimation {
|
||||
self.tableView.insertSections(IndexSet(integer: 1), with: .none)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getStatuses(for range: RequestRange = .default, completion: @escaping Client.Callback<[Status]>) {
|
||||
let request: Request<[Status]>
|
||||
switch kind {
|
||||
case .statuses:
|
||||
request = Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true)
|
||||
case .withReplies:
|
||||
request = Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: false)
|
||||
case .onlyMedia:
|
||||
request = Account.getStatuses(accountID, range: range, onlyMedia: true, pinned: false, excludeReplies: false)
|
||||
}
|
||||
mastodonController.run(request, completion: completion)
|
||||
}
|
||||
|
||||
private func getPinnedStatuses(completion: @escaping Client.Callback<[Status]>) {
|
||||
let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false)
|
||||
mastodonController.run(request, completion: completion)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
||||
@objc func refreshStatuses(_ sender: UIRefreshControl) {
|
||||
guard let newer = newer else { return }
|
||||
|
||||
getStatuses(for: newer) { (response) in
|
||||
guard case let .success(newStatuses, pagination) = response else {
|
||||
// todo: error message
|
||||
return
|
||||
}
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
|
||||
// if there's no newer request range (because no statuses were returned),
|
||||
// we don't want to change the current newer pagination, so that we can
|
||||
// continue to load statuses newer than whatever was last loaded
|
||||
if let newer = pagination?.newer {
|
||||
self.newer = newer
|
||||
}
|
||||
|
||||
let indexPaths = (0..<newStatuses.count).map { IndexPath(row: $0, section: 1) }
|
||||
DispatchQueue.main.async {
|
||||
self.timelineSegments[0].insert(contentsOf: newStatuses.map { ($0.id, .unknown) }, at: 0)
|
||||
UIView.performWithoutAnimation {
|
||||
self.tableView.insertRows(at: indexPaths, with: .none)
|
||||
}
|
||||
|
||||
self.refreshControl!.endRefreshing()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if kind == .statuses {
|
||||
getPinnedStatuses { (response) in
|
||||
guard case let .success(newPinnedStatuses, _) = response else {
|
||||
// todo: error message
|
||||
return
|
||||
}
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: newPinnedStatuses) {
|
||||
let oldPinnedStatuses = self.pinnedStatuses
|
||||
let pinnedStatuses = newPinnedStatuses.map { (status) -> (id: String, state: StatusState) in
|
||||
let state: StatusState
|
||||
if let (_, oldState) = oldPinnedStatuses.first(where: { $0.id == status.id }) {
|
||||
state = oldState
|
||||
} else {
|
||||
state = .unknown
|
||||
}
|
||||
return (status.id, state)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.pinnedStatuses = pinnedStatuses
|
||||
UIView.performWithoutAnimation {
|
||||
self.tableView.reloadSections(IndexSet(integer: 0), with: .none)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Table view data source
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
// 1 for pinned, rest for timeline
|
||||
return 1 + timelineSegments.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
if section == 0 {
|
||||
return pinnedStatuses.count
|
||||
} else {
|
||||
return timelineSegments[section - 1].count
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell
|
||||
|
||||
cell.delegate = self
|
||||
|
||||
if indexPath.section == 0 {
|
||||
cell.showPinned = true
|
||||
let (id, state) = pinnedStatuses[indexPath.row]
|
||||
cell.updateUI(statusID: id, state: state)
|
||||
} else {
|
||||
cell.showPinned = false
|
||||
let (id, state) = timelineSegments[indexPath.section - 1][indexPath.row]
|
||||
cell.updateUI(statusID: id, state: state)
|
||||
}
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
// todo: if scrolling up, remove statuses at bottom like timeline VC
|
||||
|
||||
// load older statuses if at bottom
|
||||
if timelineSegments.count > 0,
|
||||
indexPath.section == timelineSegments.count,
|
||||
indexPath.row == timelineSegments[indexPath.section - 1].count - 1 {
|
||||
guard let older = older else { return }
|
||||
|
||||
getStatuses(for: older) { (response) in
|
||||
guard case let .success(newStatuses, pagination) = response else {
|
||||
// todo: error message
|
||||
return
|
||||
}
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
|
||||
// if there is no older request range, we want to set ours to nil
|
||||
// otherwise we would end up loading the same statuses again
|
||||
self.older = pagination?.older
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let start = self.timelineSegments[indexPath.section - 1].count
|
||||
let indexPaths = (0..<newStatuses.count).map { IndexPath(row: start + $0, section: indexPath.section) }
|
||||
self.timelineSegments[indexPath.section - 1].append(contentsOf: newStatuses.map { ($0.id, .unknown) })
|
||||
UIView.performWithoutAnimation {
|
||||
self.tableView.insertRows(at: indexPaths, with: .none)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.leadingSwipeActionsConfiguration()
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ProfileStatusesViewController {
|
||||
enum Kind {
|
||||
case statuses, withReplies, onlyMedia
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileStatusesViewController: StatusTableViewCellDelegate {
|
||||
var apiController: MastodonController { mastodonController }
|
||||
|
||||
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
|
||||
// causes the table view to recalculate the cell heights
|
||||
tableView.beginUpdates()
|
||||
tableView.endUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileStatusesViewController: UITableViewDataSourcePrefetching {
|
||||
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||
for indexPath in indexPaths {
|
||||
let statusID: String
|
||||
if indexPath.section == 0 {
|
||||
statusID = pinnedStatuses[indexPath.row].id
|
||||
} else {
|
||||
statusID = timelineSegments[indexPath.section - 1][indexPath.row].id
|
||||
}
|
||||
guard let status = mastodonController.persistentContainer.status(for: statusID) else { continue }
|
||||
_ = ImageCache.avatars.get(status.account.avatar, completion: nil)
|
||||
for attachment in status.attachments where attachment.kind == .image {
|
||||
_ = ImageCache.attachments.get(attachment.url, completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
|
||||
for indexPath in indexPaths {
|
||||
let statusID: String
|
||||
if indexPath.section == 0 {
|
||||
statusID = pinnedStatuses[indexPath.row].id
|
||||
} else {
|
||||
statusID = timelineSegments[indexPath.section - 1][indexPath.row].id
|
||||
}
|
||||
guard let status = mastodonController.persistentContainer.status(for: statusID) else { continue }
|
||||
ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
|
||||
for attachment in status.attachments where attachment.kind == .image {
|
||||
ImageCache.avatars.cancelWithoutCallback(attachment.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,354 +0,0 @@
|
|||
//
|
||||
// ProfileTableViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/27/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import SafariServices
|
||||
|
||||
class ProfileTableViewController: EnhancedTableViewController {
|
||||
|
||||
weak var mastodonController: MastodonController!
|
||||
|
||||
var accountID: String!
|
||||
|
||||
var pinnedStatuses: [(id: String, state: StatusState)] = []
|
||||
var timelineSegments: [[(id: String, state: StatusState)]] = []
|
||||
|
||||
var older: RequestRange?
|
||||
var newer: RequestRange?
|
||||
|
||||
private var loadingVC: LoadingViewController? = nil
|
||||
private var loaded = false
|
||||
|
||||
init(accountID: String?, mastodonController: MastodonController) {
|
||||
self.mastodonController = mastodonController
|
||||
|
||||
self.accountID = accountID
|
||||
|
||||
super.init(style: .plain)
|
||||
|
||||
self.refreshControl = UIRefreshControl()
|
||||
refreshControl!.addTarget(self, action: #selector(refreshStatuses(_:)), for: .valueChanged)
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composePressed(_:)))
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemeneted")
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let id = accountID, let container = mastodonController?.persistentContainer {
|
||||
container.backgroundContext.perform {
|
||||
container.account(for: id, in: container.backgroundContext)?.decrementReferenceCount()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 140
|
||||
|
||||
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: nil), forCellReuseIdentifier: "statusCell")
|
||||
tableView.register(UINib(nibName: "ProfileHeaderTableViewCell", bundle: nil), forCellReuseIdentifier: "headerCell")
|
||||
|
||||
tableView.prefetchDataSource = self
|
||||
|
||||
if accountID == nil {
|
||||
loadingVC = LoadingViewController()
|
||||
embedChild(loadingVC!)
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
if !loaded, let accountID = accountID {
|
||||
loaded = true
|
||||
loadingVC?.removeViewAndController()
|
||||
loadingVC = nil
|
||||
|
||||
if mastodonController.persistentContainer.account(for: accountID) != nil {
|
||||
updateAccountUI()
|
||||
} else {
|
||||
loadingVC = LoadingViewController()
|
||||
embedChild(loadingVC!)
|
||||
let request = Client.getAccount(id: accountID)
|
||||
mastodonController.run(request) { (response) in
|
||||
guard case let .success(account, _) = response else {
|
||||
let alert = UIAlertController(title: "Something Went Wrong", message: "Couldn't load the selected account", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { (_) in
|
||||
self.navigationController!.popViewController(animated: true)
|
||||
}))
|
||||
DispatchQueue.main.async {
|
||||
self.present(alert, animated: true)
|
||||
}
|
||||
return
|
||||
}
|
||||
self.mastodonController.persistentContainer.addOrUpdate(account: account, incrementReferenceCount: true) { (_) in
|
||||
DispatchQueue.main.async {
|
||||
self.updateAccountUI()
|
||||
self.tableView.reloadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateAccountUI() {
|
||||
updateUIForPreferences()
|
||||
|
||||
getStatuses(onlyPinned: true) { (response) in
|
||||
guard case let .success(statuses, _) = response else { fatalError() }
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
self.pinnedStatuses = statuses.map { ($0.id, .unknown) }
|
||||
let indexPaths = (0..<statuses.count).map { IndexPath(row: $0, section: 1) }
|
||||
DispatchQueue.main.async {
|
||||
UIView.performWithoutAnimation {
|
||||
self.tableView.insertRows(at: indexPaths, with: .none)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getStatuses() { response in
|
||||
guard case let .success(statuses, pagination) = response else { fatalError() }
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
self.timelineSegments.append(statuses.map { ($0.id, .unknown) })
|
||||
|
||||
self.older = pagination?.older
|
||||
self.newer = pagination?.newer
|
||||
|
||||
DispatchQueue.main.async {
|
||||
UIView.performWithoutAnimation {
|
||||
self.tableView.insertSections(IndexSet(integer: 2), with: .none)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func updateUIForPreferences() {
|
||||
guard let accountID = accountID, let account = mastodonController.persistentContainer.account(for: accountID) else { return }
|
||||
navigationItem.title = account.displayNameWithoutCustomEmoji
|
||||
}
|
||||
|
||||
func getStatuses(for range: RequestRange = .default, onlyPinned: Bool = false, completion: @escaping Client.Callback<[Status]>) {
|
||||
let request = Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: onlyPinned, excludeReplies: !Preferences.shared.showRepliesInProfiles)
|
||||
mastodonController.run(request, completion: completion)
|
||||
}
|
||||
|
||||
func sendMessageMentioning() {
|
||||
guard let account = mastodonController.persistentContainer.account(for: accountID) else { fatalError("Missing cached account \(accountID!)") }
|
||||
let vc = UINavigationController(rootViewController: ComposeViewController(mentioningAcct: account.acct, mastodonController: mastodonController))
|
||||
present(vc, animated: true)
|
||||
}
|
||||
|
||||
// MARK: - Table view data source
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
// 1 section for header, 1 section for pinned, rest for timeline
|
||||
return 2 + timelineSegments.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
if section == 0 {
|
||||
return accountID == nil || mastodonController.persistentContainer.account(for: accountID) == nil ? 0 : 1
|
||||
} else if section == 1 {
|
||||
return pinnedStatuses.count
|
||||
} else {
|
||||
return timelineSegments[section - 2].count
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
switch indexPath.section {
|
||||
case 0:
|
||||
guard let cell = tableView.dequeueReusableCell(withIdentifier: "headerCell", for: indexPath) as? ProfileHeaderTableViewCell else { fatalError() }
|
||||
cell.selectionStyle = .none
|
||||
cell.delegate = self
|
||||
cell.updateUI(for: accountID)
|
||||
return cell
|
||||
case 1:
|
||||
guard let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as? TimelineStatusTableViewCell else { fatalError() }
|
||||
let (id, state) = pinnedStatuses[indexPath.row]
|
||||
cell.showPinned = true
|
||||
cell.delegate = self
|
||||
cell.updateUI(statusID: id, state: state)
|
||||
return cell
|
||||
default:
|
||||
guard let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as? TimelineStatusTableViewCell else { fatalError() }
|
||||
let (id, state) = timelineSegments[indexPath.section - 2][indexPath.row]
|
||||
cell.delegate = self
|
||||
cell.updateUI(statusID: id, state: state)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Table view delegate
|
||||
|
||||
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
// todo: if scrolling up, remove statuses at bottom like timeline VC
|
||||
|
||||
// load older statuses if at bottom
|
||||
if timelineSegments.count > 0 && indexPath.section - 1 == timelineSegments.count && indexPath.row == timelineSegments[indexPath.section - 2].count - 1 {
|
||||
guard let older = older else { return }
|
||||
|
||||
getStatuses(for: older) { response in
|
||||
guard case let .success(newStatuses, pagination) = response else { fatalError() }
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
|
||||
self.older = pagination?.older
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let start = self.timelineSegments[indexPath.section - 2].count
|
||||
let indexPaths = (0..<newStatuses.count).map { IndexPath(row: start + $0, section: indexPath.section) }
|
||||
self.timelineSegments[indexPath.section - 2].append(contentsOf: newStatuses.map { ($0.id, .unknown) })
|
||||
UIView.performWithoutAnimation {
|
||||
self.tableView.insertRows(at: indexPaths, with: .none)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.leadingSwipeActionsConfiguration()
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
|
||||
}
|
||||
|
||||
@objc func refreshStatuses(_ sender: Any) {
|
||||
guard let newer = newer else { return }
|
||||
|
||||
getStatuses(for: newer) { response in
|
||||
guard case let .success(newStatuses, pagination) = response else { fatalError() }
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
|
||||
if let newer = pagination?.newer {
|
||||
self.newer = newer
|
||||
}
|
||||
|
||||
let indexPaths = (0..<newStatuses.count).map { IndexPath(row: $0, section: 2) }
|
||||
DispatchQueue.main.async {
|
||||
self.timelineSegments[0].insert(contentsOf: newStatuses.map { ($0.id, .unknown) }, at: 0)
|
||||
UIView.performWithoutAnimation {
|
||||
self.tableView.insertRows(at: indexPaths, with: .none)
|
||||
}
|
||||
|
||||
self.refreshControl?.endRefreshing()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getStatuses(onlyPinned: true) { (response) in
|
||||
guard case let .success(newPinnedStatuses, _) = response else { fatalError() }
|
||||
self.mastodonController.persistentContainer.addAll(statuses: newPinnedStatuses) {
|
||||
let oldPinnedStatuses = self.pinnedStatuses
|
||||
var pinnedStatuses = [(id: String, state: StatusState)]()
|
||||
for status in newPinnedStatuses {
|
||||
let state: StatusState
|
||||
if let (_, oldState) = oldPinnedStatuses.first(where: { $0.id == status.id }) {
|
||||
state = oldState
|
||||
} else {
|
||||
state = .unknown
|
||||
}
|
||||
pinnedStatuses.append((status.id, state))
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.pinnedStatuses = pinnedStatuses
|
||||
UIView.performWithoutAnimation {
|
||||
self.tableView.reloadSections(IndexSet(integer: 1), with: .none)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func composePressed(_ sender: Any) {
|
||||
sendMessageMentioning()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ProfileTableViewController: StatusTableViewCellDelegate {
|
||||
var apiController: MastodonController { mastodonController }
|
||||
|
||||
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
|
||||
// causes the table view to recalculate the cell heights
|
||||
tableView.beginUpdates()
|
||||
tableView.endUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileTableViewController: ProfileHeaderTableViewCellDelegate {
|
||||
func showMoreOptions(cell: ProfileHeaderTableViewCell) {
|
||||
let account = mastodonController.persistentContainer.account(for: accountID)!
|
||||
|
||||
func showActivityController(activities: [UIActivity]) {
|
||||
let activityController = UIActivityViewController(activityItems: [account.url, AccountActivityItemSource(account)], applicationActivities: activities)
|
||||
activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(viewController: self, url: account.url)
|
||||
activityController.popoverPresentationController?.sourceView = cell.moreButtonVisualEffectView
|
||||
self.present(activityController, animated: true)
|
||||
}
|
||||
|
||||
if account.id == mastodonController.account.id {
|
||||
showActivityController(activities: [OpenInSafariActivity()])
|
||||
} else {
|
||||
let request = Client.getRelationships(accounts: [account.id])
|
||||
mastodonController.run(request) { (response) in
|
||||
var customActivities: [UIActivity] = [OpenInSafariActivity()]
|
||||
if case let .success(results, _) = response, let relationship = results.first {
|
||||
let toggleFollowActivity = relationship.following ? UnfollowAccountActivity() : FollowAccountActivity()
|
||||
customActivities.insert(toggleFollowActivity, at: 0)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
showActivityController(activities: customActivities)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileTableViewController: UITableViewDataSourcePrefetching {
|
||||
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||
for indexPath in indexPaths where indexPath.section > 1 {
|
||||
let statusID = timelineSegments[indexPath.section - 2][indexPath.row].id
|
||||
guard let status = mastodonController.persistentContainer.status(for: statusID) else { continue }
|
||||
_ = ImageCache.avatars.get(status.account.avatar, completion: nil)
|
||||
for attachment in status.attachments {
|
||||
_ = ImageCache.attachments.get(attachment.url, completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
|
||||
for indexPath in indexPaths where indexPath.section > 1 {
|
||||
let statusID = timelineSegments[indexPath.section - 2][indexPath.row].id
|
||||
guard let status = mastodonController.persistentContainer.status(for: statusID) else { continue }
|
||||
ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
|
||||
for attachment in status.attachments {
|
||||
ImageCache.attachments.cancelWithoutCallback(attachment.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,242 @@
|
|||
//
|
||||
// ProfileViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 7/3/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import Combine
|
||||
|
||||
class ProfileViewController: UIPageViewController {
|
||||
|
||||
weak var mastodonController: MastodonController!
|
||||
|
||||
// todo: does this still need to be settable?
|
||||
var accountID: String! {
|
||||
didSet {
|
||||
updateAccountUI()
|
||||
pageControllers.forEach { $0.accountID = accountID }
|
||||
}
|
||||
}
|
||||
|
||||
private var accountUpdater: Cancellable?
|
||||
|
||||
private(set) var currentIndex: Int!
|
||||
let pageControllers: [ProfileStatusesViewController]
|
||||
var currentViewController: ProfileStatusesViewController {
|
||||
pageControllers[currentIndex]
|
||||
}
|
||||
|
||||
private var headerView: ProfileHeaderView!
|
||||
|
||||
init(accountID: String?, mastodonController: MastodonController) {
|
||||
self.accountID = accountID
|
||||
self.mastodonController = mastodonController
|
||||
|
||||
self.pageControllers = [
|
||||
ProfileStatusesViewController(accountID: accountID, kind: .statuses, mastodonController: mastodonController),
|
||||
ProfileStatusesViewController(accountID: accountID, kind: .withReplies, mastodonController: mastodonController),
|
||||
ProfileStatusesViewController(accountID: accountID, kind: .onlyMedia, mastodonController: mastodonController)
|
||||
]
|
||||
|
||||
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
mastodonController.persistentContainer.account(for: accountID)?.decrementReferenceCount()
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning))
|
||||
if #available(iOS 14.0, *) {
|
||||
composeButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: [
|
||||
UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in
|
||||
self.composeDirectMentioning()
|
||||
})
|
||||
])
|
||||
}
|
||||
navigationItem.rightBarButtonItem = composeButton
|
||||
|
||||
headerView = ProfileHeaderView.create()
|
||||
headerView.delegate = self
|
||||
|
||||
selectPage(at: 0, animated: false)
|
||||
|
||||
currentViewController.tableView.tableHeaderView = headerView
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
headerView.widthAnchor.constraint(equalTo: view.widthAnchor),
|
||||
])
|
||||
|
||||
accountUpdater = mastodonController.persistentContainer.accountSubject
|
||||
.filter { [weak self] in $0 == self?.accountID }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] (_) in self?.updateAccountUI() }
|
||||
|
||||
if mastodonController.persistentContainer.account(for: accountID) != nil {
|
||||
headerView.updateUI(for: accountID)
|
||||
updateAccountUI()
|
||||
} else {
|
||||
let req = Client.getAccount(id: accountID)
|
||||
mastodonController.run(req) { [weak self] (response) in
|
||||
guard let self = self else { return }
|
||||
guard case let .success(account, _) = response else { fatalError() }
|
||||
self.mastodonController.persistentContainer.addOrUpdate(account: account, incrementReferenceCount: true) { (account) in
|
||||
DispatchQueue.main.async {
|
||||
self.updateAccountUI()
|
||||
self.headerView.updateUI(for: self.accountID)
|
||||
self.pageControllers.forEach {
|
||||
$0.updateUI(account: account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateAccountUI() {
|
||||
guard let account = mastodonController.persistentContainer.account(for: accountID) else { return }
|
||||
navigationItem.title = account.displayNameWithoutCustomEmoji
|
||||
}
|
||||
|
||||
private func selectPage(at index: Int, animated: Bool, completion: ((Bool) -> Void)? = nil) {
|
||||
let direction: UIPageViewController.NavigationDirection = currentIndex == nil || index - currentIndex > 0 ? .forward : .reverse
|
||||
currentIndex = index
|
||||
|
||||
guard let old = viewControllers?.first as? ProfileStatusesViewController else {
|
||||
// if old doesn't exist, we're selecting the initial view controller, so moving the header around isn't necessary
|
||||
// since it will be added in viewDidLoad
|
||||
setViewControllers([pageControllers[index]], direction: direction, animated: animated, completion: completion)
|
||||
return
|
||||
}
|
||||
let new = pageControllers[index]
|
||||
|
||||
let headerHeight = self.headerView.bounds.height
|
||||
|
||||
// Store old's content offset so it can be transferred to new
|
||||
let prevOldContentOffset = old.tableView.contentOffset
|
||||
// Remove the header, inset the table content by the same amount, and adjust the offset so the cells don't move
|
||||
old.tableView.tableHeaderView = nil
|
||||
old.tableView.contentInset = UIEdgeInsets(top: headerHeight, left: 0, bottom: 0, right: 0)
|
||||
old.tableView.contentOffset.y -= headerHeight
|
||||
|
||||
// Add the header to ourself temporarily, and constrain it to the same position it was in
|
||||
self.view.addSubview(self.headerView)
|
||||
let tempTopConstraint = self.headerView.topAnchor.constraint(equalTo: self.view.topAnchor, constant: -(prevOldContentOffset.y + old.tableView.safeAreaInsets.top))
|
||||
NSLayoutConstraint.activate([
|
||||
self.headerView.widthAnchor.constraint(equalTo: self.view.widthAnchor),
|
||||
tempTopConstraint
|
||||
])
|
||||
|
||||
// Setup the inset in new, in case it hasn't been already
|
||||
new.tableView.contentInset = UIEdgeInsets(top: headerHeight, left: 0, bottom: 0, right: 0)
|
||||
// Match the scroll positions
|
||||
new.tableView.contentOffset = old.tableView.contentOffset
|
||||
|
||||
// Actually switch pages
|
||||
setViewControllers([pageControllers[index]], direction: direction, animated: animated) { (finished) in
|
||||
// Defer everything one run-loop iteration, otherwise altering the tableView's contentInset/Offset causes it to jump around during the animation
|
||||
DispatchQueue.main.async {
|
||||
// Move the header to the new table view
|
||||
new.tableView.tableHeaderView = self.headerView
|
||||
// Remove the inset, and set the offset back to old's original one, prior to removing the header
|
||||
new.tableView.contentInset = .zero
|
||||
new.tableView.contentOffset = prevOldContentOffset
|
||||
|
||||
// Deactivate the top constraint, otherwise it sticks around
|
||||
tempTopConstraint.isActive = false
|
||||
// Re-add the width constraint since it was removed by re-parenting the view
|
||||
// Why was the width constraint removed, but the top one not? Good question, I have no idea.
|
||||
NSLayoutConstraint.activate([
|
||||
self.headerView.widthAnchor.constraint(equalTo: self.view.widthAnchor)
|
||||
])
|
||||
|
||||
// Layout and update the table view, otherwise the content jumps around when first scrolling it,
|
||||
// if old was not scrolled all the way to the top
|
||||
new.tableView.layoutIfNeeded()
|
||||
UIView.performWithoutAnimation {
|
||||
new.tableView.performBatchUpdates(nil, completion: nil)
|
||||
}
|
||||
|
||||
completion?(finished)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
||||
@objc private func composeMentioning() {
|
||||
if let account = mastodonController.persistentContainer.account(for: accountID) {
|
||||
compose(mentioningAcct: account.acct)
|
||||
}
|
||||
}
|
||||
|
||||
private func composeDirectMentioning() {
|
||||
if let account = mastodonController.persistentContainer.account(for: accountID) {
|
||||
let draft = mastodonController.createDraft(mentioningAcct: account.acct)
|
||||
draft.visibility = .direct
|
||||
compose(editing: draft)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ProfileViewController: TuskerNavigationDelegate {
|
||||
var apiController: MastodonController { mastodonController }
|
||||
}
|
||||
|
||||
extension ProfileViewController: ProfileHeaderViewDelegate {
|
||||
func profileHeader(_ view: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int) {
|
||||
// disable user interaction on segmented control while switching pages to prevent
|
||||
// race condition from trying to switch to multiple pages simultaneously
|
||||
view.pagesSegmentedControl.isUserInteractionEnabled = false
|
||||
selectPage(at: newIndex, animated: true) { (finished) in
|
||||
view.pagesSegmentedControl.isUserInteractionEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
func profileHeader(_ view: ProfileHeaderView, showMoreOptionsFor accountID: String, sourceView: UIView) {
|
||||
let account = mastodonController.persistentContainer.account(for: accountID)!
|
||||
|
||||
func showActivityController(activities: [UIActivity]) {
|
||||
let activityController = UIActivityViewController(activityItems: [account.url, AccountActivityItemSource(account)], applicationActivities: activities)
|
||||
activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(navigator: self, url: account.url)
|
||||
activityController.popoverPresentationController?.sourceView = sourceView
|
||||
self.present(activityController, animated: true)
|
||||
}
|
||||
|
||||
if account.id == mastodonController.account.id {
|
||||
showActivityController(activities: [OpenInSafariActivity()])
|
||||
} else {
|
||||
let request = Client.getRelationships(accounts: [account.id])
|
||||
mastodonController.run(request) { (response) in
|
||||
var customActivities: [UIActivity] = [OpenInSafariActivity()]
|
||||
if case let .success(results, _) = response, let relationship = results.first {
|
||||
let toggleFollowActivity = relationship.following ? UnfollowAccountActivity() : FollowAccountActivity()
|
||||
customActivities.insert(toggleFollowActivity, at: 0)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
showActivityController(activities: customActivities)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileViewController: TabBarScrollableViewController {
|
||||
func tabBarScrollToTop() {
|
||||
pageControllers[currentIndex].tabBarScrollToTop()
|
||||
}
|
||||
}
|
|
@ -28,7 +28,7 @@ extension SearchResultsViewControllerDelegate {
|
|||
|
||||
class SearchResultsViewController: EnhancedTableViewController {
|
||||
|
||||
let mastodonController: MastodonController!
|
||||
weak var mastodonController: MastodonController!
|
||||
|
||||
weak var exploreNavigationController: UINavigationController?
|
||||
weak var delegate: SearchResultsViewControllerDelegate?
|
||||
|
@ -109,6 +109,15 @@ class SearchResultsViewController: EnhancedTableViewController {
|
|||
return super.targetViewController(forAction: action, sender: sender)
|
||||
}
|
||||
|
||||
func loadResults(from source: SearchResultsViewController) {
|
||||
currentQuery = source.currentQuery
|
||||
if let sourceDataSource = source.dataSource {
|
||||
dataSource.apply(sourceDataSource.snapshot())
|
||||
}
|
||||
// todo: check if the search needs to be performed before searching
|
||||
// performSearch(query: currentQuery)
|
||||
}
|
||||
|
||||
func performSearch(query: String?) {
|
||||
guard let query = query, !query.isEmpty else {
|
||||
self.dataSource.apply(NSDiffableDataSourceSnapshot<Section, Item>())
|
||||
|
@ -134,15 +143,19 @@ class SearchResultsViewController: EnhancedTableViewController {
|
|||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
|
||||
self.mastodonController.persistentContainer.performBatchUpdates({ (context, addAccounts, addStatuses) in
|
||||
if oldSnapshot.indexOfSection(.accounts) != nil {
|
||||
oldSnapshot.itemIdentifiers(inSection: .accounts).forEach { (item) in
|
||||
guard case let .account(id) = item else { return }
|
||||
self.mastodonController.persistentContainer.account(for: id, in: context)?.decrementReferenceCount()
|
||||
}
|
||||
}
|
||||
|
||||
if oldSnapshot.indexOfSection(.statuses) != nil {
|
||||
oldSnapshot.itemIdentifiers(inSection: .statuses).forEach { (item) in
|
||||
guard case let .status(id, _) = item else { return }
|
||||
self.mastodonController.persistentContainer.status(for: id, in: context)?.decrementReferenceCount()
|
||||
}
|
||||
}
|
||||
|
||||
if self.onlySections.contains(.accounts) && !results.accounts.isEmpty {
|
||||
snapshot.appendSections([.accounts])
|
||||
|
@ -208,6 +221,20 @@ extension SearchResultsViewController {
|
|||
case account(String)
|
||||
case hashtag(Hashtag)
|
||||
case status(String, StatusState)
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case let .account(id):
|
||||
hasher.combine("account")
|
||||
hasher.combine(id)
|
||||
case let .hashtag(hashtag):
|
||||
hasher.combine("hashtag")
|
||||
hasher.combine(hashtag.url)
|
||||
case let .status(id, _):
|
||||
hasher.combine("status")
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DataSource: UITableViewDiffableDataSource<Section, Item> {
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
//
|
||||
// SearchViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 6/24/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class SearchViewController: UIViewController {
|
||||
|
||||
weak var mastodonController: MastodonController!
|
||||
|
||||
var resultsController: SearchResultsViewController!
|
||||
var searchController: UISearchController!
|
||||
|
||||
var searchControllerStatusOnAppearance: Bool? = nil
|
||||
|
||||
init(mastodonController: MastodonController) {
|
||||
self.mastodonController = mastodonController
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
title = NSLocalizedString("Search", comment: "search tab title")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
resultsController = SearchResultsViewController(mastodonController: mastodonController)
|
||||
resultsController.exploreNavigationController = self.navigationController
|
||||
searchController = UISearchController(searchResultsController: resultsController)
|
||||
searchController.searchResultsUpdater = resultsController
|
||||
searchController.searchBar.autocapitalizationType = .none
|
||||
searchController.searchBar.delegate = resultsController
|
||||
definesPresentationContext = true
|
||||
|
||||
navigationItem.searchController = searchController
|
||||
navigationItem.hidesSearchBarWhenScrolling = false
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
// this is a workaround for the issue that setting isActive on a search controller that is not visible
|
||||
// does not cause it to automatically become active once it becomes visible
|
||||
// see FB7814561
|
||||
if let active = searchControllerStatusOnAppearance {
|
||||
searchController.isActive = active
|
||||
searchControllerStatusOnAppearance = nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -31,6 +31,8 @@ class InstanceTimelineViewController: TimelineTableViewController {
|
|||
}
|
||||
}
|
||||
|
||||
var browsingEnabled = true
|
||||
|
||||
init(for url: URL, parentMastodonController: MastodonController) {
|
||||
self.parentMastodonController = parentMastodonController
|
||||
|
||||
|
@ -66,36 +68,15 @@ class InstanceTimelineViewController: TimelineTableViewController {
|
|||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = super.tableView(tableView, cellForRowAt: indexPath) as! TimelineStatusTableViewCell
|
||||
cell.delegate = nil
|
||||
cell.overrideMastodonController = mastodonController
|
||||
cell.delegate = browsingEnabled ? self : nil
|
||||
return cell
|
||||
}
|
||||
|
||||
// MARK: - Table view delegate
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
// no-op, we don't currently support viewing whole conversations from other instances
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
// don't show other screens or actions for other instances
|
||||
return nil
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
// don't show swipe actions for other instances
|
||||
return nil
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
// only show more actions for other instances
|
||||
let more = UIContextualAction(style: .normal, title: "More") { (action, view, completion) in
|
||||
completion(true)
|
||||
self.showMoreOptions(forStatus: self.timelineSegments[indexPath.section][indexPath.row].id, sourceView: tableView.cellForRow(at: indexPath))
|
||||
}
|
||||
more.image = UIImage(systemName: "ellipsis.circle.fill")
|
||||
more.backgroundColor = .lightGray
|
||||
return UISwipeActionsConfiguration(actions: [more])
|
||||
guard browsingEnabled else { return }
|
||||
super.tableView(tableView, didSelectRowAt: indexPath)
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
class TimelineTableViewController: EnhancedTableViewController {
|
||||
class TimelineTableViewController: EnhancedTableViewController, StatusTableViewCellDelegate {
|
||||
|
||||
var timeline: Timeline!
|
||||
weak var mastodonController: MastodonController!
|
||||
|
@ -179,11 +179,12 @@ class TimelineTableViewController: EnhancedTableViewController {
|
|||
mastodonController.run(request) { response in
|
||||
guard case let .success(newStatuses, pagination) = response else { fatalError() }
|
||||
self.older = pagination?.older
|
||||
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
|
||||
DispatchQueue.main.async {
|
||||
let newRows = self.timelineSegments.last!.count..<(self.timelineSegments.last!.count + newStatuses.count)
|
||||
let newIndexPaths = newRows.map { IndexPath(row: $0, section: self.timelineSegments.count - 1) }
|
||||
self.timelineSegments[self.timelineSegments.count - 1].append(contentsOf: newStatuses.map { ($0.id, .unknown) })
|
||||
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
|
||||
DispatchQueue.main.async {
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
self.tableView.insertRows(at: newIndexPaths, with: .none)
|
||||
}
|
||||
|
@ -205,7 +206,7 @@ class TimelineTableViewController: EnhancedTableViewController {
|
|||
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc func refreshStatuses(_ sender: Any) {
|
||||
guard let newer = newer else { return }
|
||||
|
@ -213,15 +214,16 @@ class TimelineTableViewController: EnhancedTableViewController {
|
|||
let request = Client.getStatuses(timeline: timeline, range: newer)
|
||||
mastodonController.run(request) { response in
|
||||
guard case let .success(newStatuses, pagination) = response else { fatalError() }
|
||||
self.newer = pagination?.newer
|
||||
self.timelineSegments[0].insert(contentsOf: newStatuses.map { ($0.id, .unknown) }, at: 0)
|
||||
|
||||
// If there is no new newer pagination, don't reset it, so that the user can continue refreshing for more recent statuses
|
||||
// Otherwise, when no new statuses were loaded, it would get reset and the the user would be unable to refresh
|
||||
if let newer = pagination?.newer {
|
||||
self.newer = newer
|
||||
}
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
|
||||
DispatchQueue.main.async {
|
||||
self.timelineSegments[0].insert(contentsOf: newStatuses.map { ($0.id, .unknown) }, at: 0)
|
||||
let newIndexPaths = (0..<newStatuses.count).map {
|
||||
IndexPath(row: $0, section: 0)
|
||||
}
|
||||
|
@ -242,16 +244,18 @@ class TimelineTableViewController: EnhancedTableViewController {
|
|||
compose()
|
||||
}
|
||||
|
||||
}
|
||||
// MARK: - TuskerNavigationDelegate
|
||||
|
||||
extension TimelineTableViewController: StatusTableViewCellDelegate {
|
||||
var apiController: MastodonController { mastodonController }
|
||||
|
||||
// MARK: - StatusTableViewCellDelegate
|
||||
|
||||
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
|
||||
// causes the table view to recalculate the cell heights
|
||||
tableView.beginUpdates()
|
||||
tableView.endUpdates()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension TimelineTableViewController: UITableViewDataSourcePrefetching {
|
||||
|
@ -259,7 +263,7 @@ extension TimelineTableViewController: UITableViewDataSourcePrefetching {
|
|||
for indexPath in indexPaths {
|
||||
guard let status = mastodonController.persistentContainer.status(for: statusID(for: indexPath)) else { continue }
|
||||
_ = ImageCache.avatars.get(status.account.avatar, completion: nil)
|
||||
for attachment in status.attachments {
|
||||
for attachment in status.attachments where attachment.kind == .image {
|
||||
_ = ImageCache.attachments.get(attachment.url, completion: nil)
|
||||
}
|
||||
}
|
||||
|
@ -272,7 +276,7 @@ extension TimelineTableViewController: UITableViewDataSourcePrefetching {
|
|||
guard indexPath.section < timelineSegments.count, indexPath.row < timelineSegments[indexPath.section].count,
|
||||
let status = mastodonController.persistentContainer.status(for: statusID(for: indexPath)) else { continue }
|
||||
ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
|
||||
for attachment in status.attachments {
|
||||
for attachment in status.attachments where attachment.kind == .image {
|
||||
ImageCache.attachments.cancelWithoutCallback(attachment.url)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -95,3 +95,13 @@ extension EnhancedTableViewController {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
extension EnhancedTableViewController: TabBarScrollableViewController {
|
||||
func tabBarScrollToTop() {
|
||||
if scrollViewShouldScrollToTop(tableView) {
|
||||
let topOffset = CGPoint(x: 0, y: -tableView.adjustedContentInset.top)
|
||||
tableView.setContentOffset(topOffset, animated: true)
|
||||
scrollViewDidScrollToTop(tableView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,9 +10,9 @@ import UIKit
|
|||
import SafariServices
|
||||
import Pachyderm
|
||||
|
||||
protocol MenuPreviewProvider {
|
||||
protocol MenuPreviewProvider: class {
|
||||
|
||||
typealias PreviewProviders = (content: UIContextMenuContentPreviewProvider, actions: () -> [UIAction])
|
||||
typealias PreviewProviders = (content: UIContextMenuContentPreviewProvider, actions: () -> [UIMenuElement])
|
||||
|
||||
var navigationDelegate: TuskerNavigationDelegate? { get }
|
||||
|
||||
|
@ -28,57 +28,188 @@ extension MenuPreviewProvider {
|
|||
|
||||
private var mastodonController: MastodonController? { navigationDelegate?.apiController }
|
||||
|
||||
func actionsForProfile(accountID: String, sourceView: UIView?) -> [UIAction] {
|
||||
// Default no-op implementation
|
||||
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func actionsForProfile(accountID: String, sourceView: UIView?) -> [UIMenuElement] {
|
||||
guard let mastodonController = mastodonController,
|
||||
let account = mastodonController.persistentContainer.account(for: accountID) else { return [] }
|
||||
|
||||
guard mastodonController.loggedIn else {
|
||||
return [
|
||||
createAction(identifier: "sendmessage", title: "Send Message", systemImageName: "envelope", handler: { (_) in
|
||||
self.navigationDelegate?.compose(mentioning: account.acct)
|
||||
}),
|
||||
createAction(identifier: "openinsafari", title: "Open in Safari", systemImageName: "safari", handler: { (_) in
|
||||
self.navigationDelegate?.selected(url: account.url)
|
||||
}),
|
||||
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { (_) in
|
||||
openInSafariAction(url: account.url),
|
||||
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
|
||||
guard let self = self else { return }
|
||||
self.navigationDelegate?.showMoreOptions(forAccount: accountID, sourceView: sourceView)
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
var actionsSection: [UIMenuElement] = [
|
||||
createAction(identifier: "sendmessage", title: "Send Message", systemImageName: "envelope", handler: { [weak self] (_) in
|
||||
guard let self = self else { return }
|
||||
self.navigationDelegate?.compose(mentioningAcct: account.acct)
|
||||
}),
|
||||
]
|
||||
|
||||
// todo: handle pre-iOS 14
|
||||
#if SDK_IOS_14
|
||||
if accountID != mastodonController.account.id,
|
||||
#available(iOS 14.0, *) {
|
||||
actionsSection.append(UIDeferredMenuElement({ (elementHandler) in
|
||||
guard let mastodonController = self.mastodonController else {
|
||||
elementHandler([])
|
||||
return
|
||||
}
|
||||
let request = Client.getRelationships(accounts: [account.id])
|
||||
mastodonController.run(request) { [weak self] (response) in
|
||||
if let self = self,
|
||||
case let .success(results, _) = response,
|
||||
let relationship = results.first {
|
||||
let following = relationship.following
|
||||
DispatchQueue.main.async {
|
||||
elementHandler([
|
||||
self.createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.minus", handler: { (_) in
|
||||
let request = (following ? Account.unfollow : Account.follow)(accountID)
|
||||
mastodonController.run(request) { (_) in
|
||||
}
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
#endif
|
||||
|
||||
let shareSection = [
|
||||
openInSafariAction(url: account.url),
|
||||
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
|
||||
guard let self = self else { return }
|
||||
self.navigationDelegate?.showMoreOptions(forAccount: accountID, sourceView: sourceView)
|
||||
})
|
||||
]
|
||||
|
||||
return [
|
||||
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection),
|
||||
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: actionsSection),
|
||||
]
|
||||
}
|
||||
|
||||
func actionsForURL(_ url: URL, sourceView: UIView?) -> [UIAction] {
|
||||
return [
|
||||
createAction(identifier: "openinsafari", title: "Open in Safari", systemImageName: "safari", handler: { (_) in
|
||||
self.navigationDelegate?.selected(url: url)
|
||||
}),
|
||||
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { (_) in
|
||||
openInSafariAction(url: url),
|
||||
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
|
||||
guard let self = self else { return }
|
||||
self.navigationDelegate?.showMoreOptions(forURL: url, sourceView: sourceView)
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
func actionsForHashtag(_ hashtag: Hashtag, sourceView: UIView?) -> [UIAction] {
|
||||
return actionsForURL(hashtag.url, sourceView: sourceView)
|
||||
func actionsForHashtag(_ hashtag: Hashtag, sourceView: UIView?) -> [UIMenuElement] {
|
||||
let account = mastodonController!.accountInfo!
|
||||
let saved = SavedDataManager.shared.isSaved(hashtag: hashtag, for: account)
|
||||
|
||||
let actionsSection = [
|
||||
createAction(identifier: "save", title: saved ? "Unsave Hashtag" : "Save Hashtag", systemImageName: "number", handler: { (_) in
|
||||
if saved {
|
||||
SavedDataManager.shared.remove(hashtag: hashtag, for: account)
|
||||
} else {
|
||||
SavedDataManager.shared.add(hashtag: hashtag, for: account)
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
let shareSection = actionsForURL(hashtag.url, sourceView: sourceView)
|
||||
|
||||
return [
|
||||
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection),
|
||||
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: actionsSection),
|
||||
]
|
||||
}
|
||||
|
||||
func actionsForStatus(statusID: String, sourceView: UIView?) -> [UIAction] {
|
||||
func actionsForStatus(statusID: String, sourceView: UIView?) -> [UIMenuElement] {
|
||||
guard let mastodonController = mastodonController,
|
||||
let status = mastodonController.persistentContainer.status(for: statusID) else { return [] }
|
||||
|
||||
guard mastodonController.loggedIn else {
|
||||
return [
|
||||
createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { (_) in
|
||||
self.navigationDelegate?.reply(to: statusID)
|
||||
}),
|
||||
createAction(identifier: "openinsafari", title: "Open in Safari", systemImageName: "safari", handler: { (_) in
|
||||
self.navigationDelegate?.selected(url: status.url!)
|
||||
}),
|
||||
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { (_) in
|
||||
openInSafariAction(url: status.url!),
|
||||
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
|
||||
guard let self = self else { return }
|
||||
self.navigationDelegate?.showMoreOptions(forStatus: statusID, sourceView: sourceView)
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
let bookmarked = status.bookmarked ?? false
|
||||
let muted = status.muted
|
||||
|
||||
var actionsSection = [
|
||||
createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { [weak self] (_) in
|
||||
guard let self = self else { return }
|
||||
self.navigationDelegate?.compose(inReplyToID: statusID)
|
||||
}),
|
||||
createAction(identifier: "bookmark", title: bookmarked ? "Unbookmark" : "Bookmark", systemImageName: bookmarked ? "bookmark.fill" : "bookmark", handler: { [weak self] (_) in
|
||||
guard let self = self else { return }
|
||||
let request = (bookmarked ? Status.unbookmark : Status.bookmark)(statusID)
|
||||
self.mastodonController?.run(request) { (response) in
|
||||
if case let .success(status, _) = response {
|
||||
self.mastodonController?.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
||||
}
|
||||
}
|
||||
}),
|
||||
createAction(identifier: "mute", title: muted ? "Unmute" : "Mute", systemImageName: muted ? "speaker" : "speaker.slash", handler: { [weak self] (_) in
|
||||
guard let self = self else { return }
|
||||
let request = (muted ? Status.unmuteConversation : Status.muteConversation)(statusID)
|
||||
self.mastodonController?.run(request) { (response) in
|
||||
if case let .success(status, _) = response {
|
||||
self.mastodonController?.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
if mastodonController.account != nil && mastodonController.account.id == status.account.id {
|
||||
let pinned = status.pinned ?? false
|
||||
actionsSection.append(createAction(identifier: "", title: pinned ? "Unpin" : "Pin", systemImageName: pinned ? "pin.slash" : "pin", handler: { [weak self] (_) in
|
||||
guard let self = self else { return }
|
||||
let request = (pinned ? Status.unpin : Status.pin)(statusID)
|
||||
self.mastodonController?.run(request, completion: { [weak self] (response) in
|
||||
guard let self = self else { return }
|
||||
if case let .success(status, _) = response {
|
||||
self.mastodonController?.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
let shareSection = [
|
||||
openInSafariAction(url: status.url!),
|
||||
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
|
||||
guard let self = self else { return }
|
||||
self.navigationDelegate?.showMoreOptions(forStatus: statusID, sourceView: sourceView)
|
||||
}),
|
||||
]
|
||||
|
||||
return [
|
||||
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection),
|
||||
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: actionsSection),
|
||||
]
|
||||
}
|
||||
|
||||
private func createAction(identifier: String, title: String, systemImageName: String, handler: @escaping UIActionHandler) -> UIAction {
|
||||
return UIAction(title: title, image: UIImage(systemName: systemImageName), identifier: UIAction.Identifier(identifier), discoverabilityTitle: nil, attributes: [], state: .off, handler: handler)
|
||||
}
|
||||
|
||||
private func openInSafariAction(url: URL) -> UIAction {
|
||||
return createAction(identifier: "openinsafari", title: "Open in Safari", systemImageName: "safari", handler: { (_) in
|
||||
self.navigationDelegate?.selected(url: url, allowUniversalLinks: false)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension LargeImageViewController: CustomPreviewPresenting {
|
||||
|
|
|
@ -63,3 +63,11 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
extension SegmentedPageViewController: TabBarScrollableViewController {
|
||||
func tabBarScrollToTop() {
|
||||
if let scrollableVC = pageControllers[currentIndex] as? TabBarScrollableViewController {
|
||||
scrollableVC.tabBarScrollToTop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
//
|
||||
// TabBarScrollableViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 7/3/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
protocol TabBarScrollableViewController: UIViewController {
|
||||
func tabBarScrollToTop()
|
||||
}
|
|
@ -7,6 +7,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import Intents
|
||||
import Pachyderm
|
||||
|
||||
class UserActivityManager {
|
||||
|
@ -50,7 +51,8 @@ class UserActivityManager {
|
|||
static func handleNewPost(activity: NSUserActivity) {
|
||||
// TODO: check not currently showing compose screen
|
||||
let mentioning = activity.userInfo?["mentioning"] as? String
|
||||
let composeVC = ComposeViewController(mentioningAcct: mentioning, mastodonController: mastodonController)
|
||||
let draft = mastodonController.createDraft(mentioningAcct: mentioning)
|
||||
let composeVC = ComposeHostingController(draft: draft, mastodonController: mastodonController)
|
||||
present(UINavigationController(rootViewController: composeVC))
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.space.vaccor.Tusker</string>
|
||||
</array>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.photos-library</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
|
@ -10,52 +10,11 @@ import UIKit
|
|||
import SafariServices
|
||||
import Pachyderm
|
||||
|
||||
protocol TuskerNavigationDelegate: class {
|
||||
|
||||
protocol TuskerNavigationDelegate: UIViewController {
|
||||
var apiController: MastodonController { get }
|
||||
|
||||
func show(_ vc: UIViewController)
|
||||
|
||||
func selected(account accountID: String)
|
||||
|
||||
func selected(mention: Mention)
|
||||
|
||||
func selected(tag: Hashtag)
|
||||
|
||||
func selected(url: URL)
|
||||
|
||||
func selected(status statusID: String)
|
||||
|
||||
func selected(status statusID: String, state: StatusState)
|
||||
|
||||
func compose()
|
||||
|
||||
func compose(mentioning: String?)
|
||||
|
||||
func reply(to statusID: String)
|
||||
|
||||
func reply(to statusID: String, mentioningAcct: String?)
|
||||
|
||||
func loadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView) -> LoadingLargeImageViewController
|
||||
|
||||
func showLoadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView)
|
||||
|
||||
func gallery(attachments: [Attachment], sourceViews: [UIImageView?], startIndex: Int) -> GalleryViewController
|
||||
|
||||
func showGallery(attachments: [Attachment], sourceViews: [UIImageView?], startIndex: Int)
|
||||
|
||||
func showMoreOptions(forStatus statusID: String, sourceView: UIView?)
|
||||
|
||||
func showMoreOptions(forAccount accountID: String, sourceView: UIView?)
|
||||
|
||||
func showMoreOptions(forURL url: URL, sourceView: UIView?)
|
||||
|
||||
func showFollowedByList(accountIDs: [String])
|
||||
|
||||
func statusActionAccountList(action: StatusActionAccountListTableViewController.ActionType, statusID: String, statusState state: StatusState, accountIDs: [String]?) -> StatusActionAccountListTableViewController
|
||||
}
|
||||
|
||||
extension TuskerNavigationDelegate where Self: UIViewController {
|
||||
extension TuskerNavigationDelegate {
|
||||
|
||||
func show(_ vc: UIViewController) {
|
||||
if vc is LargeImageViewController || vc is GalleryViewController || vc is SFSafariViewController {
|
||||
|
@ -67,23 +26,23 @@ extension TuskerNavigationDelegate where Self: UIViewController {
|
|||
|
||||
func selected(account accountID: String) {
|
||||
// don't open if the account is the same as the current one
|
||||
if let profileController = self as? ProfileTableViewController,
|
||||
if let profileController = self as? ProfileViewController,
|
||||
profileController.accountID == accountID {
|
||||
return
|
||||
}
|
||||
|
||||
show(ProfileTableViewController(accountID: accountID, mastodonController: apiController), sender: self)
|
||||
show(ProfileViewController(accountID: accountID, mastodonController: apiController), sender: self)
|
||||
}
|
||||
|
||||
func selected(mention: Mention) {
|
||||
show(ProfileTableViewController(accountID: mention.id, mastodonController: apiController), sender: self)
|
||||
show(ProfileViewController(accountID: mention.id, mastodonController: apiController), sender: self)
|
||||
}
|
||||
|
||||
func selected(tag: Hashtag) {
|
||||
show(HashtagTimelineViewController(for: tag, mastodonController: apiController), sender: self)
|
||||
}
|
||||
|
||||
func selected(url: URL) {
|
||||
func selected(url: URL, allowUniversalLinks: Bool = true) {
|
||||
func openSafari() {
|
||||
if Preferences.shared.useInAppSafari {
|
||||
let config = SFSafariViewController.Configuration()
|
||||
|
@ -94,7 +53,7 @@ extension TuskerNavigationDelegate where Self: UIViewController {
|
|||
}
|
||||
}
|
||||
|
||||
if (Preferences.shared.openLinksInApps) {
|
||||
if allowUniversalLinks && Preferences.shared.openLinksInApps {
|
||||
UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { (success) in
|
||||
if (!success) {
|
||||
openSafari()
|
||||
|
@ -120,27 +79,17 @@ extension TuskerNavigationDelegate where Self: UIViewController {
|
|||
show(ConversationTableViewController(for: statusID, state: state, mastodonController: apiController), sender: self)
|
||||
}
|
||||
|
||||
// protocols can't have parameter defaults, so this stub is necessary to fulfill the protocol req
|
||||
func compose() {
|
||||
compose(mentioning: nil)
|
||||
}
|
||||
func compose(editing draft: Draft) {
|
||||
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
|
||||
|
||||
func compose(mentioning: String?) {
|
||||
let compose = ComposeViewController(mentioningAcct: mentioning, mastodonController: apiController)
|
||||
let vc = UINavigationController(rootViewController: compose)
|
||||
vc.presentationController?.delegate = compose
|
||||
present(vc, animated: true)
|
||||
}
|
||||
|
||||
func reply(to statusID: String) {
|
||||
reply(to: statusID, mentioningAcct: nil)
|
||||
}
|
||||
|
||||
func reply(to statusID: String, mentioningAcct: String?) {
|
||||
let compose = ComposeViewController(inReplyTo: statusID, mentioningAcct: mentioningAcct, mastodonController: apiController)
|
||||
let vc = UINavigationController(rootViewController: compose)
|
||||
vc.presentationController?.delegate = compose
|
||||
present(vc, animated: true)
|
||||
func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil) {
|
||||
let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct)
|
||||
compose(editing: draft)
|
||||
}
|
||||
|
||||
func loadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView) -> LoadingLargeImageViewController {
|
||||
|
@ -169,7 +118,7 @@ extension TuskerNavigationDelegate where Self: UIViewController {
|
|||
OpenInSafariActivity()
|
||||
]
|
||||
let activityController = UIActivityViewController(activityItems: [url], applicationActivities: customActivites)
|
||||
activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(viewController: self, url: url)
|
||||
activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(navigator: self, url: url)
|
||||
return activityController
|
||||
}
|
||||
|
||||
|
@ -177,6 +126,10 @@ extension TuskerNavigationDelegate where Self: UIViewController {
|
|||
guard let status = apiController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID)") }
|
||||
guard let url = status.url else { fatalError("Missing url for status \(statusID)") }
|
||||
|
||||
// on iOS 14+, all these custom actions are in the context menu and don't need to be in the share sheet
|
||||
if #available(iOS 14.0, *) {
|
||||
return UIActivityViewController(activityItems: [url, StatusActivityItemSource(status)], applicationActivities: nil)
|
||||
} else {
|
||||
var customActivites: [UIActivity] = [
|
||||
OpenInSafariActivity(),
|
||||
(status.bookmarked ?? false) ? UnbookmarkStatusActivity() : BookmarkStatusActivity(),
|
||||
|
@ -189,21 +142,26 @@ extension TuskerNavigationDelegate where Self: UIViewController {
|
|||
}
|
||||
|
||||
let activityController = UIActivityViewController(activityItems: [url, StatusActivityItemSource(status)], applicationActivities: customActivites)
|
||||
activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(viewController: self, url: url)
|
||||
activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(navigator: self, url: url)
|
||||
return activityController
|
||||
}
|
||||
}
|
||||
|
||||
private func moreOptions(forAccount accountID: String) -> UIActivityViewController {
|
||||
guard let account = apiController.persistentContainer.account(for: accountID) else { fatalError("Missing cached account \(accountID)") }
|
||||
|
||||
if #available(iOS 14.0, *) {
|
||||
return UIActivityViewController(activityItems: [account.url, AccountActivityItemSource(account)], applicationActivities: nil)
|
||||
} else {
|
||||
let customActivities: [UIActivity] = [
|
||||
OpenInSafariActivity(),
|
||||
]
|
||||
|
||||
let activityController = UIActivityViewController(activityItems: [account.url, AccountActivityItemSource(account)], applicationActivities: customActivities)
|
||||
activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(viewController: self, url: account.url)
|
||||
activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(navigator: self, url: account.url)
|
||||
return activityController
|
||||
}
|
||||
}
|
||||
|
||||
func showMoreOptions(forStatus statusID: String, sourceView: UIView?) {
|
||||
let vc = moreOptions(forStatus: statusID)
|
||||
|
|
|
@ -0,0 +1,148 @@
|
|||
/// BlurHash reference decoder implementation.
|
||||
/// From https://github.com/woltapp/blurhash/blob/b23214ddcab803fe1ec9a3e6b20558caf33a23a5/Swift/BlurHashDecode.swift
|
||||
import UIKit
|
||||
|
||||
extension UIImage {
|
||||
public convenience init?(blurHash: String, size: CGSize, punch: Float = 1) {
|
||||
guard blurHash.count >= 6 else { return nil }
|
||||
|
||||
let sizeFlag = String(blurHash[0]).decode83()
|
||||
let numY = (sizeFlag / 9) + 1
|
||||
let numX = (sizeFlag % 9) + 1
|
||||
|
||||
let quantisedMaximumValue = String(blurHash[1]).decode83()
|
||||
let maximumValue = Float(quantisedMaximumValue + 1) / 166
|
||||
|
||||
guard blurHash.count == 4 + 2 * numX * numY else { return nil }
|
||||
|
||||
let colours: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in
|
||||
if i == 0 {
|
||||
let value = String(blurHash[2 ..< 6]).decode83()
|
||||
return decodeDC(value)
|
||||
} else {
|
||||
let value = String(blurHash[4 + i * 2 ..< 4 + i * 2 + 2]).decode83()
|
||||
return decodeAC(value, maximumValue: maximumValue * punch)
|
||||
}
|
||||
}
|
||||
|
||||
let width = Int(size.width)
|
||||
let height = Int(size.height)
|
||||
let bytesPerRow = width * 3
|
||||
guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil }
|
||||
CFDataSetLength(data, bytesPerRow * height)
|
||||
guard let pixels = CFDataGetMutableBytePtr(data) else { return nil }
|
||||
|
||||
for y in 0 ..< height {
|
||||
for x in 0 ..< width {
|
||||
var r: Float = 0
|
||||
var g: Float = 0
|
||||
var b: Float = 0
|
||||
|
||||
for j in 0 ..< numY {
|
||||
for i in 0 ..< numX {
|
||||
let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height))
|
||||
let colour = colours[i + j * numX]
|
||||
r += colour.0 * basis
|
||||
g += colour.1 * basis
|
||||
b += colour.2 * basis
|
||||
}
|
||||
}
|
||||
|
||||
let intR = UInt8(linearTosRGB(r))
|
||||
let intG = UInt8(linearTosRGB(g))
|
||||
let intB = UInt8(linearTosRGB(b))
|
||||
|
||||
pixels[3 * x + 0 + y * bytesPerRow] = intR
|
||||
pixels[3 * x + 1 + y * bytesPerRow] = intG
|
||||
pixels[3 * x + 2 + y * bytesPerRow] = intB
|
||||
}
|
||||
}
|
||||
|
||||
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
|
||||
|
||||
guard let provider = CGDataProvider(data: data) else { return nil }
|
||||
guard let cgImage = CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 24, bytesPerRow: bytesPerRow,
|
||||
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: bitmapInfo, provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent) else { return nil }
|
||||
|
||||
self.init(cgImage: cgImage)
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeDC(_ value: Int) -> (Float, Float, Float) {
|
||||
let intR = value >> 16
|
||||
let intG = (value >> 8) & 255
|
||||
let intB = value & 255
|
||||
return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB))
|
||||
}
|
||||
|
||||
private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) {
|
||||
let quantR = value / (19 * 19)
|
||||
let quantG = (value / 19) % 19
|
||||
let quantB = value % 19
|
||||
|
||||
let rgb = (
|
||||
signPow((Float(quantR) - 9) / 9, 2) * maximumValue,
|
||||
signPow((Float(quantG) - 9) / 9, 2) * maximumValue,
|
||||
signPow((Float(quantB) - 9) / 9, 2) * maximumValue
|
||||
)
|
||||
|
||||
return rgb
|
||||
}
|
||||
|
||||
private func signPow(_ value: Float, _ exp: Float) -> Float {
|
||||
return copysign(pow(abs(value), exp), value)
|
||||
}
|
||||
|
||||
private func linearTosRGB(_ value: Float) -> Int {
|
||||
let v = max(0, min(1, value))
|
||||
if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) }
|
||||
else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) }
|
||||
}
|
||||
|
||||
private func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float {
|
||||
let v = Float(Int64(value)) / 255
|
||||
if v <= 0.04045 { return v / 12.92 }
|
||||
else { return pow((v + 0.055) / 1.055, 2.4) }
|
||||
}
|
||||
|
||||
private let encodeCharacters: [String] = {
|
||||
return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) }
|
||||
}()
|
||||
|
||||
private let decodeCharacters: [String: Int] = {
|
||||
var dict: [String: Int] = [:]
|
||||
for (index, character) in encodeCharacters.enumerated() {
|
||||
dict[character] = index
|
||||
}
|
||||
return dict
|
||||
}()
|
||||
|
||||
private extension String {
|
||||
func decode83() -> Int {
|
||||
var value: Int = 0
|
||||
for character in self {
|
||||
if let digit = decodeCharacters[String(character)] {
|
||||
value = value * 83 + digit
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
subscript (offset: Int) -> Character {
|
||||
return self[index(startIndex, offsetBy: offset)]
|
||||
}
|
||||
|
||||
subscript (bounds: CountableClosedRange<Int>) -> Substring {
|
||||
let start = index(startIndex, offsetBy: bounds.lowerBound)
|
||||
let end = index(startIndex, offsetBy: bounds.upperBound)
|
||||
return self[start...end]
|
||||
}
|
||||
|
||||
subscript (bounds: CountableRange<Int>) -> Substring {
|
||||
let start = index(startIndex, offsetBy: bounds.lowerBound)
|
||||
let end = index(startIndex, offsetBy: bounds.upperBound)
|
||||
return self[start..<end]
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue