Compare commits

..

1 Commits

222 changed files with 4565 additions and 10649 deletions

3
.gitmodules vendored
View File

@ -1,3 +1,6 @@
[submodule "SwiftSoup"]
path = SwiftSoup
url = git://github.com/scinfu/SwiftSoup.git
[submodule "Cache"] [submodule "Cache"]
path = Cache path = Cache
url = git@github.com:hyperoslo/Cache.git url = git@github.com:hyperoslo/Cache.git

View File

@ -1,138 +0,0 @@
# Changelog
## 2020.1 (10)
This build is a hotfix for a couple pressing issues. The changelog for the previous build is included below.
Bugfixes:
- Fix crash when opening Preferences while signed in with a deleted account
- Fix visibility and content warning not being copied when replying to a post
## 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.
Features/Improvements:
- Add mute/unmute conversation status action
- iPadOS: Add pointer interactions to remove attachment button, gallery view share/dismiss buttons
- Disable reblog button for direct/followers-only posts
- On Pleroma, the reblog button is still enabled for your own followers-only posts to match Pleroma's "Boost to original audience" feature.
- Add preference to always display status visibilities below account avatars
- Add preference to show reply indicators for statuses in timelines
- Show share/dismiss controls and image description for gifv attachments
- 'Share' is currently disabled for gifv attachments, it will be enabled in a future build
- Add crash report helper
- If the app detects that it crashed the last time it was running, it will allow you to review the crash report and email it to me
- Add Recognize Text context menu option for images on the Compose screen
- This uses iOS' builtin Vision framework to perform on-device OCR and generate an image description from the recognized text
- Tweak attachment previews to always have a 16:9 aspect ratio
Bugfixes:
- Fix account/status More actions not working
- Improve share sheet loading speed
- Fix crash when loading bookmarks
- Prompt for Photos access before showing photo picker. Prevents empty sheet displaying.
- Fix profile fields not displaying and improve layout
- Fix profile header image not displaying the first time an account is loaded
- Don't show Follow action for your own account
- Fix attachments on the Compose screen being cut-off above the home indicator on iPhone X-style devices
- Fix audio being played by other apps pausing when displaying a gifv attachment on Mastodon
## 2020.1 (5)
The main focus of this update has been switching to using CoreData internally to cache/synchronize the most up-to-date versions of all statuses. Currently, this does not provide any new functionality, however, it lays the groundwork for several significant features coming in the future, including multiple window support on iPadOS and state restoration/persistence between launches.
Even though there aren't a huge number of new features in this build, a great deal has changed under the hood. As such, this build may suffer somewhat in the stability department. Please bear with me and report any issues you encounter; you can send me a message on the fediverse, email me at me@shadowfacts.net, or file an issue on the project issue tracker at https://git.shadowfacts.net/shadowfacts/Tusker/issues. Thank you!
Features:
- iPadOS: Add pointer interactions to status action buttons and profile header button
- iPadOS: Allow scrolling w/ trackpad/magic mouse to dismiss attachment gallery
- iPadOS: Enable interactive push gesture with trackpad/magic mouse
- Add drawing attachments using PencilKit
- Long-press to open context menu on the 'Add Attachment' button on the Compose screen, select 'Draw Something'
- Supports Apple Pencil on iPad, including tilt and pressure sensitivity
- Add avatar and instance domain in accounts switcher in Preferences
- Show gifv attachments on Mastodon
- Currently doesn't show attachment description or share/close buttons
- Add 'Clear Cache' option to Preferences -> Advanced for debugging
Bugfixes:
- Fix size of attachment previews in context menu
- Fix previewing audio/video attachments
- Fix incorrect image size during attachment expand/shrink animation
- Prevent avatars in grouped action notification from overflowing the cell and hiding the timestamp
- Fix text in conversation main statuses not being de-selectable
- Fix scroll-to-top sometimes not scrolling all the way to the top
- Fix account profile descriptions being squashed in the follow notification account list

2
Gifu

@ -1 +1 @@
Subproject commit 9b1a6461aa3b5f66cb0ed3a50c5523db0b4fb007 Subproject commit ed572f53ce58b8e23499abeb3a926033cbe480f7

View File

@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import Combine
/** /**
The base Mastodon API client. The base Mastodon API client.
@ -26,7 +27,7 @@ public class Client {
public var timeoutInterval: TimeInterval = 60 public var timeoutInterval: TimeInterval = 60
static let decoder: JSONDecoder = { lazy var decoder: JSONDecoder = {
let decoder = JSONDecoder() let decoder = JSONDecoder()
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ" formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
@ -36,16 +37,6 @@ public class Client {
return decoder 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) { public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
self.baseURL = baseURL self.baseURL = baseURL
self.accessToken = accessToken self.accessToken = accessToken
@ -60,24 +51,29 @@ public class Client {
let task = session.dataTask(with: request) { data, response, error in let task = session.dataTask(with: request) { data, response, error in
if let error = error { if let error = error {
completion(.failure(.networkError(error))) completion(.failure(error))
return return
} }
guard let data = data, guard let data = data,
let response = response as? HTTPURLResponse else { let response = response as? HTTPURLResponse else {
completion(.failure(.invalidResponse)) completion(.failure(Error.invalidResponse))
return return
} }
guard response.statusCode == 200 else { guard response.statusCode == 200 else {
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data) let mastodonError = try? self.decoder.decode(MastodonError.self, from: data)
let error: Error = mastodonError.flatMap { .mastodonError($0.description) } ?? .unexpectedStatus(response.statusCode) let error = mastodonError.flatMap { Error.mastodonError($0.description) } ?? Error.unknownError
completion(.failure(error)) completion(.failure(error))
return return
} }
guard let result = try? Client.decoder.decode(Result.self, from: data) else { guard let result = try? self.decoder.decode(Result.self, from: data) else {
completion(.failure(.invalidModel)) completion(.failure(Error.invalidModel))
return return
} }
if var result = result as? ClientModel {
result.client = self
} else if var result = result as? [ClientModel] {
result.client = self
}
let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init) let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
completion(.success(result, pagination)) completion(.success(result, pagination))
@ -85,6 +81,43 @@ public class Client {
task.resume() task.resume()
} }
public func run<Result>(_ request: Request<Result>) -> Promise<(Result, Pagination?)> {
return Promise { (resolve, reject) in
self.run(request) { (response) in
switch response {
case let .success(result, pagination):
resolve((result, pagination))
case let .failure(error):
reject(error)
}
}
}
}
public func run<Result: Decodable>(_ request: Request<Result>) -> AnyPublisher<(Result, Pagination?), Swift.Error> {
guard let request = createURLRequest(request: request) else {
return Fail(error: Error.invalidRequest).eraseToAnyPublisher()
}
return session.dataTaskPublisher(for: request)
.mapError { Error.urlError($0) }
.tryMap {
guard let response = $0.response as? HTTPURLResponse else {
throw Error.invalidResponse
}
guard response.statusCode == 200 else {
if let mastodonError = try? self.decoder.decode(MastodonError.self, from: $0.data) {
throw Error.mastodonError(mastodonError.description)
} else {
throw Error.unknownError
}
}
let result = try self.decoder.decode(Result.self, from: $0.data)
let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
return (result, pagination)
}
.eraseToAnyPublisher()
}
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? { func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil } guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
components.path = request.path components.path = request.path
@ -102,7 +135,7 @@ public class Client {
// MARK: - Authorization // MARK: - Authorization
public func registerApp(name: String, redirectURI: String, scopes: [Scope], website: URL? = nil, completion: @escaping Callback<RegisteredApplication>) { 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: ParametersBody([ let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: .parameters([
"client_name" => name, "client_name" => name,
"redirect_uris" => redirectURI, "redirect_uris" => redirectURI,
"scopes" => scopes.scopeString, "scopes" => scopes.scopeString,
@ -119,7 +152,7 @@ public class Client {
} }
public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) { public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) {
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: ParametersBody([ let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: .parameters([
"client_id" => clientID, "client_id" => clientID,
"client_secret" => clientSecret, "client_secret" => clientSecret,
"grant_type" => "authorization_code", "grant_type" => "authorization_code",
@ -178,13 +211,13 @@ public class Client {
} }
public static func block(domain: String) -> Request<Empty> { public static func block(domain: String) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: ParametersBody([ return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: .parameters([
"domain" => domain "domain" => domain
])) ]))
} }
public static func unblock(domain: String) -> Request<Empty> { public static func unblock(domain: String) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: ParametersBody([ return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: .parameters([
"domain" => domain "domain" => domain
])) ]))
} }
@ -195,7 +228,7 @@ public class Client {
} }
public static func createFilter(phrase: String, context: [Filter.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> { 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: ParametersBody([ return Request<Filter>(method: .post, path: "/api/v1/filters", body: .parameters([
"phrase" => phrase, "phrase" => phrase,
"irreversible" => irreversible, "irreversible" => irreversible,
"whole_word" => wholeWord, "whole_word" => wholeWord,
@ -219,7 +252,7 @@ public class Client {
} }
public static func followRemote(acct: String) -> Request<Account> { public static func followRemote(acct: String) -> Request<Account> {
return Request<Account>(method: .post, path: "/api/v1/follows", body: ParametersBody(["uri" => acct])) return Request<Account>(method: .post, path: "/api/v1/follows", body: .parameters(["uri" => acct]))
} }
// MARK: - Lists // MARK: - Lists
@ -232,12 +265,12 @@ public class Client {
} }
public static func createList(title: String) -> Request<List> { public static func createList(title: String) -> Request<List> {
return Request<List>(method: .post, path: "/api/v1/lists", body: ParametersBody(["title" => title])) return Request<List>(method: .post, path: "/api/v1/lists", body: .parameters(["title" => title]))
} }
// MARK: - Media // MARK: - Media
public static func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil) -> Request<Attachment> { 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: FormDataBody([ return Request<Attachment>(method: .post, path: "/api/v1/media", body: .formData([
"description" => description, "description" => description,
"focus" => focus "focus" => focus
], attachment)) ], attachment))
@ -269,7 +302,7 @@ public class Client {
} }
public static func report(account: Account, statuses: [Status], comment: String) -> Request<Report> { public static func report(account: Account, statuses: [Status], comment: String) -> Request<Report> {
return Request<Report>(method: .post, path: "/api/v1/reports", body: ParametersBody([ return Request<Report>(method: .post, path: "/api/v1/reports", body: .parameters([
"account_id" => account.id, "account_id" => account.id,
"comment" => comment "comment" => comment
] + "status_ids" => statuses.map { $0.id })) ] + "status_ids" => statuses.map { $0.id }))
@ -297,7 +330,7 @@ public class Client {
spoilerText: String? = nil, spoilerText: String? = nil,
visibility: Status.Visibility? = nil, visibility: Status.Visibility? = nil,
language: String? = nil) -> Request<Status> { language: String? = nil) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([ return Request<Status>(method: .post, path: "/api/v1/statuses", body: .parameters([
"status" => text, "status" => text,
"content_type" => contentType.mimeType, "content_type" => contentType.mimeType,
"in_reply_to_id" => inReplyTo, "in_reply_to_id" => inReplyTo,
@ -324,32 +357,12 @@ public class Client {
} }
extension Client { extension Client {
public enum Error: LocalizedError { public enum Error: Swift.Error {
case networkError(Swift.Error) case unknownError
case unexpectedStatus(Int)
case invalidRequest case invalidRequest
case invalidResponse case invalidResponse
case invalidModel case invalidModel
case mastodonError(String) case mastodonError(String)
case urlError(URLError)
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)"
}
}
} }
} }

View File

@ -0,0 +1,39 @@
//
// ClientModel.swift
// Pachyderm
//
// Created by Shadowfacts on 9/9/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
protocol ClientModel {
var client: Client! { get set }
}
extension Array where Element == ClientModel {
var client: Client! {
get {
return first?.client
}
set {
for var el in self {
el.client = newValue
}
}
}
}
extension Array where Element: ClientModel {
var client: Client! {
get {
return first?.client
}
set {
for var el in self {
el.client = newValue
}
}
}
}

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
public final class Account: AccountProtocol, Decodable { public class Account: Decodable {
public let id: String public let id: String
public let username: String public let username: String
public let acct: String public let acct: String
@ -27,7 +27,7 @@ public final class Account: AccountProtocol, Decodable {
public private(set) var emojis: [Emoji] public private(set) var emojis: [Emoji]
public let moved: Bool? public let moved: Bool?
public let movedTo: Account? public let movedTo: Account?
public let fields: [Field] public let fields: [Field]?
public let bot: Bool? public let bot: Bool?
public required init(from decoder: Decoder) throws { public required init(from decoder: Decoder) throws {
@ -47,9 +47,9 @@ public final class Account: AccountProtocol, Decodable {
self.avatar = try container.decode(URL.self, forKey: .avatar) self.avatar = try container.decode(URL.self, forKey: .avatar)
self.avatarStatic = try container.decode(URL.self, forKey: .avatarStatic) self.avatarStatic = try container.decode(URL.self, forKey: .avatarStatic)
self.header = try container.decode(URL.self, forKey: .header) self.header = try container.decode(URL.self, forKey: .header)
self.headerStatic = try container.decode(URL.self, forKey: .headerStatic) self.headerStatic = try container.decode(URL.self, forKey: .url)
self.emojis = try container.decode([Emoji].self, forKey: .emojis) self.emojis = try container.decode([Emoji].self, forKey: .emojis)
self.fields = (try? container.decode([Field].self, forKey: .fields)) ?? [] self.fields = try? container.decode([Field].self, forKey: .fields)
self.bot = try? container.decode(Bool.self, forKey: .bot) self.bot = try? container.decode(Bool.self, forKey: .bot)
if let moved = try? container.decode(Bool.self, forKey: .moved) { if let moved = try? container.decode(Bool.self, forKey: .moved) {
@ -115,7 +115,7 @@ public final class Account: AccountProtocol, Decodable {
} }
public static func mute(_ account: Account, notifications: Bool? = nil) -> Request<Relationship> { public static func mute(_ account: Account, notifications: Bool? = nil) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/mute", body: ParametersBody([ return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/mute", body: .parameters([
"notifications" => notifications "notifications" => notifications
])) ]))
} }

View File

@ -8,19 +8,18 @@
import Foundation import Foundation
public class Attachment: Codable { public class Attachment: Decodable {
public let id: String public let id: String
public let kind: Kind public let kind: Kind
public let url: URL public let url: URL
public let remoteURL: URL? public let remoteURL: URL?
public let previewURL: URL? public let previewURL: URL
public let textURL: URL? public let textURL: URL?
public let meta: Metadata? public let meta: Metadata?
public let description: String? public let description: String?
public let blurHash: String?
public static func update(_ attachment: Attachment, focus: (Float, Float)?, description: String?) -> Request<Attachment> { 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: FormDataBody([ return Request<Attachment>(method: .put, path: "/api/v1/media/\(attachment.id)", body: .formData([
"description" => (description ?? attachment.description), "description" => (description ?? attachment.description),
"focus" => focus "focus" => focus
], nil)) ], nil))
@ -30,13 +29,20 @@ public class Attachment: Codable {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(String.self, forKey: .id) self.id = try container.decode(String.self, forKey: .id)
self.kind = try container.decode(Kind.self, forKey: .kind) self.kind = try container.decode(Kind.self, forKey: .kind)
self.url = try container.decode(URL.self, forKey: .url) self.url = URL(lenient: try container.decode(String.self, forKey: .url))!
self.previewURL = try? container.decode(URL?.self, forKey: .previewURL) if let remote = try? container.decode(String.self, forKey: .remoteURL) {
self.remoteURL = try? container.decode(URL?.self, forKey: .remoteURL) self.remoteURL = URL(lenient: remote.replacingOccurrences(of: " ", with: "%20"))
self.textURL = try? container.decode(URL?.self, forKey: .textURL) } else {
self.meta = try? container.decode(Metadata?.self, forKey: .meta) self.remoteURL = nil
self.description = try? container.decode(String?.self, forKey: .description) }
self.blurHash = try? container.decode(String?.self, forKey: .blurHash) self.previewURL = URL(lenient: try container.decode(String.self, forKey: .previewURL).replacingOccurrences(of: " ", with: "%20"))!
if let text = try? container.decode(String.self, forKey: .textURL) {
self.textURL = URL(lenient: text.replacingOccurrences(of: " ", with: "%20"))
} else {
self.textURL = nil
}
self.meta = try? container.decode(Metadata.self, forKey: .meta)
self.description = try? container.decode(String.self, forKey: .description)
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
@ -48,12 +54,11 @@ public class Attachment: Codable {
case textURL = "text_url" case textURL = "text_url"
case meta case meta
case description case description
case blurHash = "blurhash"
} }
} }
extension Attachment { extension Attachment {
public enum Kind: String, Codable { public enum Kind: String, Decodable {
case image case image
case video case video
case gifv case gifv
@ -63,7 +68,7 @@ extension Attachment {
} }
extension Attachment { extension Attachment {
public struct Metadata: Codable { public class Metadata: Decodable {
public let length: String? public let length: String?
public let duration: Float? public let duration: Float?
public let audioEncoding: String? public let audioEncoding: String?
@ -94,7 +99,7 @@ extension Attachment {
} }
} }
public struct ImageMetadata: Codable { public class ImageMetadata: Decodable {
public let width: Int? public let width: Int?
public let height: Int? public let height: Int?
public let size: String? public let size: String?
@ -108,3 +113,14 @@ extension Attachment {
} }
} }
} }
fileprivate extension URL {
private static let allowedChars = CharacterSet.urlHostAllowed.union(.urlPathAllowed).union(.urlQueryAllowed)
init?(lenient string: String) {
guard let escaped = string.addingPercentEncoding(withAllowedCharacters: URL.allowedChars) else {
return nil
}
self.init(string: escaped)
}
}

View File

@ -22,23 +22,6 @@ public class Card: Decodable {
public let width: Int? public let width: Int?
public let height: Int? public let height: Int?
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.url = try container.decode(URL.self, forKey: .url)
self.title = try container.decode(String.self, forKey: .title)
self.description = try container.decode(String.self, forKey: .description)
self.kind = try container.decode(Kind.self, forKey: .kind)
self.image = try? container.decode(URL.self, forKey: .image)
self.authorName = try? container.decode(String.self, forKey: .authorName)
self.authorURL = try? container.decode(URL.self, forKey: .authorURL)
self.providerName = try? container.decode(String.self, forKey: .providerName)
self.providerURL = try? container.decode(URL.self, forKey: .providerURL)
self.html = try? container.decode(String.self, forKey: .html)
self.width = try? container.decode(Int.self, forKey: .width)
self.height = try? container.decode(Int.self, forKey: .height)
}
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case url case url
case title case title

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
public class Emoji: Codable { public class Emoji: Decodable {
public let shortcode: String public let shortcode: String
public let url: URL public let url: URL
public let staticURL: URL public let staticURL: URL

View File

@ -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> { 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: ParametersBody([ return Request<Filter>(method: .put, path: "/api/v1/filters/\(filter.id)", body: .parameters([
"phrase" => (phrase ?? filter.phrase), "phrase" => (phrase ?? filter.phrase),
"irreversible" => (irreversible ?? filter.irreversible), "irreversible" => (irreversible ?? filter.irreversible),
"whole_word" => (wholeWord ?? filter.wholeWord), "whole_word" => (wholeWord ?? filter.wholeWord),

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
public class List: Decodable, Equatable, Hashable { public class List: Decodable {
public let id: String public let id: String
public let title: String public let title: String
@ -16,14 +16,6 @@ public class List: Decodable, Equatable, Hashable {
return .list(id: id) 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]> { public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts") var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts")
request.range = range request.range = range
@ -31,7 +23,7 @@ public class List: Decodable, Equatable, Hashable {
} }
public static func update(_ list: List, title: String) -> Request<List> { public static func update(_ list: List, title: String) -> Request<List> {
return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: ParametersBody(["title" => title])) return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: .parameters(["title" => title]))
} }
public static func delete(_ list: List) -> Request<Empty> { public static func delete(_ list: List) -> Request<Empty> {
@ -39,13 +31,13 @@ public class List: Decodable, Equatable, Hashable {
} }
public static func add(_ list: List, accounts accountIDs: [String]) -> Request<Empty> { public static func add(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody( return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: .parameters(
"account_ids" => accountIDs "account_ids" => accountIDs
)) ))
} }
public static func remove(_ list: List, accounts accountIDs: [String]) -> Request<Empty> { public static func remove(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody( return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: .parameters(
"account_ids" => accountIDs "account_ids" => accountIDs
)) ))
} }

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
public class Mention: Codable { public class Mention: Decodable {
public let url: URL public let url: URL
public let username: String public let username: String
public let acct: String public let acct: String

View File

@ -15,26 +15,8 @@ public class Notification: Decodable {
public let account: Account public let account: Account
public let status: Status? public let status: Status?
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(String.self, forKey: .id)
if let kind = try? container.decode(Kind.self, forKey: .kind) {
self.kind = kind
} else {
self.kind = .unknown
}
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
self.account = try container.decode(Account.self, forKey: .account)
if container.contains(.status) {
self.status = try container.decode(Status.self, forKey: .status)
} else {
self.status = nil
}
}
public static func dismiss(id notificationID: String) -> Request<Empty> { public static func dismiss(id notificationID: String) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: ParametersBody([ return Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: .parameters([
"id" => notificationID "id" => notificationID
])) ]))
} }
@ -55,7 +37,6 @@ extension Notification {
case favourite case favourite
case follow case follow
case followRequest = "follow_request" case followRequest = "follow_request"
case unknown
} }
} }

View File

@ -1,33 +0,0 @@
//
// AccountProtocol.swift
// Pachyderm
//
// Created by Shadowfacts on 4/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
public protocol AccountProtocol {
associatedtype Account: AccountProtocol
var id: String { get }
var username: String { get }
var acct: String { get }
var displayName: String { get }
var locked: Bool { get }
var createdAt: Date { get }
var followersCount: Int { get }
var followingCount: Int { get }
var statusesCount: Int { get }
var note: String { get }
var url: URL { get }
var avatar: URL { get }
var header: URL { get }
var moved: Bool? { get }
var bot: Bool? { get }
var movedTo: Account? { get }
var emojis: [Emoji] { get }
var fields: [Pachyderm.Account.Field] { get }
}

View File

@ -1,38 +0,0 @@
//
// StatusProtocol.swift
// Pachyderm
//
// Created by Shadowfacts on 4/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
public protocol StatusProtocol {
associatedtype Status: StatusProtocol
associatedtype Account: AccountProtocol
var id: String { get }
var uri: String { get }
var inReplyToID: String? { get }
var inReplyToAccountID: String? { get }
var content: String { get }
var createdAt: Date { get }
var reblogsCount: Int { get }
var favouritesCount: Int { get }
var reblogged: Bool { get }
var favourited: Bool { get }
var sensitive: Bool { get }
var spoilerText: String { get }
var visibility: Pachyderm.Status.Visibility { get }
var applicationName: String? { get }
var pinned: Bool? { get }
var bookmarked: Bool? { get }
var account: Account { get }
var reblog: Status? { get }
var attachments: [Attachment] { get }
var emojis: [Emoji] { get }
var hashtags: [Hashtag] { get }
var mentions: [Mention] { get }
}

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
public final class Status: /*StatusProtocol,*/ Decodable { public class Status: Decodable {
public let id: String public let id: String
public let uri: String public let uri: String
public let url: URL? public let url: URL?
@ -36,26 +36,23 @@ public final class Status: /*StatusProtocol,*/ Decodable {
public let language: String? public let language: String?
public let pinned: Bool? public let pinned: Bool?
public let bookmarked: Bool? public let bookmarked: Bool?
public let card: Card?
public var applicationName: String? { application?.name } public static func getContext(_ status: Status) -> Request<ConversationContext> {
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(status.id)/context")
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(statusID)/context")
} }
public static func getCard(_ status: Status) -> Request<Card> { public static func getCard(_ status: Status) -> Request<Card> {
return Request<Card>(method: .get, path: "/api/v1/statuses/\(status.id)/card") return Request<Card>(method: .get, path: "/api/v1/statuses/\(status.id)/card")
} }
public static func getFavourites(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> { public static func getFavourites(_ status: Status, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/favourited_by") var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/favourited_by")
request.range = range request.range = range
return request return request
} }
public static func getReblogs(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> { public static func getReblogs(_ status: Status, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/reblogged_by") var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/reblogged_by")
request.range = range request.range = range
return request return request
} }
@ -64,44 +61,44 @@ public final class Status: /*StatusProtocol,*/ Decodable {
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(status.id)") return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(status.id)")
} }
public static func reblog(_ statusID: String) -> Request<Status> { public static func reblog(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/reblog") return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/reblog")
} }
public static func unreblog(_ statusID: String) -> Request<Status> { public static func unreblog(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unreblog") return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unreblog")
} }
public static func favourite(_ statusID: String) -> Request<Status> { public static func favourite(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/favourite") return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/favourite")
} }
public static func unfavourite(_ statusID: String) -> Request<Status> { public static func unfavourite(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unfavourite") return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unfavourite")
} }
public static func pin(_ statusID: String) -> Request<Status> { public static func pin(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/pin") return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/pin")
} }
public static func unpin(_ statusID: String) -> Request<Status> { public static func unpin(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unpin") return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unpin")
} }
public static func bookmark(_ statusID: String) -> Request<Status> { public static func bookmark(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/bookmark") return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/bookmark")
} }
public static func unbookmark(_ statusID: String) -> Request<Status> { public static func unbookmark(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unbookmark") return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unbookmark")
} }
public static func muteConversation(_ statusID: String) -> Request<Status> { public static func muteConversation(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/mute") return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/mute")
} }
public static func unmuteConversation(_ statusID: String) -> Request<Status> { public static func unmuteConversation(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unmute") return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unmute")
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
@ -131,7 +128,6 @@ public final class Status: /*StatusProtocol,*/ Decodable {
case language case language
case pinned case pinned
case bookmarked case bookmarked
case card
} }
} }

170
Pachyderm/Promise.swift Normal file
View File

@ -0,0 +1,170 @@
//
// Promise.swift
// Pachyderm
//
// Created by Shadowfacts on 2/14/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
public class Promise<Result> {
private var handlers: [(Result) -> Void] = []
private var result: Result?
private var catchers: [(Error) -> Void] = []
private var error: Error?
func resolve(_ result: Result) {
self.result = result
self.handlers.forEach { $0(result) }
}
func reject(_ error: Error) {
self.error = error
self.catchers.forEach { $0(error) }
}
func addHandler(_ handler: @escaping (Result) -> Void) {
if let result = result {
handler(result)
} else {
handlers.append(handler)
}
}
func addCatcher(_ catcher: @escaping (Error) -> Void) {
if let error = error {
catcher(error)
} else {
catchers.append(catcher)
}
}
}
public extension Promise {
static func resolve<Result>(_ value: Result) -> Promise<Result> {
let promise = Promise<Result>()
promise.resolve(value)
return promise
}
static func reject<Result>(_ error: Error) -> Promise<Result> {
let promise = Promise<Result>()
promise.reject(error)
return promise
}
static func all<Result>(_ promises: [Promise<Result>], queue: DispatchQueue = .main) -> Promise<[Result]> {
let group = DispatchGroup()
var results = [Result?](repeating: nil, count: promises.count)
var firstError: Error?
for (index, promise) in promises.enumerated() {
group.enter()
promise.then { (res) in
queue.async {
results[index] = res
group.leave()
}
}.catch { (err) -> Void in
if firstError == nil {
firstError = err
}
group.leave()
}
}
return Promise<[Result]> { (resolve, reject) in
group.notify(queue: queue) {
if let firstError = firstError {
reject(firstError)
} else {
resolve(results.compactMap { $0 })
}
}
}
}
convenience init(resultProvider: @escaping (_ resolve: @escaping (Result) -> Void, _ reject: @escaping (Error) -> Void) -> Void) {
self.init()
resultProvider(self.resolve, self.reject)
}
convenience init<ErrorType>(_ resultProvider: @escaping ((Swift.Result<Result, ErrorType>) -> Void) -> Void) {
self.init { (resolve, reject) in
resultProvider { (result) in
switch result {
case let .success(res):
resolve(res)
case let .failure(error):
reject(error)
}
}
}
}
@discardableResult
func then(_ func: @escaping (Result) -> Void) -> Promise<Result> {
addHandler(`func`)
return self
}
func then<Next>(_ mapper: @escaping (Result) -> Promise<Next>) -> Promise<Next> {
let next = Promise<Next>()
addHandler { (parentResult) in
let newPromise = mapper(parentResult)
newPromise.addHandler(next.resolve)
newPromise.addCatcher(next.reject)
}
addCatcher(next.reject)
return next
}
func then<Next>(_ mapper: @escaping (Result) -> Next) -> Promise<Next> {
let next = Promise<Next>()
addHandler { (parentResult) in
let newResult = mapper(parentResult)
next.resolve(newResult)
}
addCatcher(next.reject)
return next
}
@discardableResult
func `catch`(_ catcher: @escaping (Error) -> Void) -> Promise<Result> {
addCatcher(catcher)
return self
}
func `catch`(_ catcher: @escaping (Error) -> Promise<Result>) -> Promise<Result> {
let next = Promise<Result>()
addHandler(next.resolve)
addCatcher { (error) in
let newPromise = catcher(error)
newPromise.addHandler(next.resolve)
newPromise.addCatcher(next.reject)
}
return next
}
func `catch`(_ catcher: @escaping (Error) -> Result) -> Promise<Result> {
let next = Promise<Result>()
addHandler(next.resolve)
addCatcher { (error) in
let newResult = catcher(error)
next.resolve(newResult)
}
return next
}
func handle(on queue: DispatchQueue) -> Promise<Result> {
return self.then { (result) in
return Promise { (resolve, reject) in
queue.async {
resolve(result)
}
}
}
}
}

View File

@ -8,82 +8,56 @@
import Foundation import Foundation
protocol Body { enum Body {
var mimeType: String? { get } case parameters([Parameter]?)
var data: Data? { get } case formData([Parameter]?, FormAttachment?)
case empty
} }
struct EmptyBody: Body { extension Body {
var mimeType: String? { nil } private static let boundary: String = "PachydermBoundary"
var data: Data? { nil }
}
struct ParametersBody: Body {
let parameters: [Parameter]?
init(_ parmaeters: [Parameter]?) { var data: Data? {
self.parameters = parmaeters switch self {
case let .parameters(parameters):
return parameters?.urlEncoded.data(using: .utf8)
case let .formData(parameters, attachment):
var data = Data()
parameters?.forEach { param in
guard let value = param.value else { return }
data.append("--\(Body.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("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")
return data
case .empty:
return nil
}
} }
var mimeType: String? { var mimeType: String? {
if parameters == nil || parameters!.isEmpty { 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 return nil
} }
return "application/x-www-form-urlencoded; charset=utf-8"
}
var data: Data? {
return parameters?.urlEncoded.data(using: .utf8)
} }
} }
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("--\(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("--\(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("--\(FormDataBody.boundary)--\r\n")
return data
}
}
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) }
}

View File

@ -14,7 +14,7 @@ public struct Request<ResultType: Decodable> {
let body: Body let body: Body
var queryParameters: [Parameter] var queryParameters: [Parameter]
init(method: Method, path: String, body: Body = EmptyBody(), queryParameters: [Parameter] = []) { init(method: Method, path: String, body: Body = .empty, queryParameters: [Parameter] = []) {
self.method = method self.method = method
self.path = path self.path = path
self.body = body self.body = body

View File

@ -10,5 +10,5 @@ import Foundation
public enum Response<Result: Decodable> { public enum Response<Result: Decodable> {
case success(Result, Pagination?) case success(Result, Pagination?)
case failure(Client.Error) case failure(Error)
} }

View File

@ -22,16 +22,16 @@ public class InstanceSelector {
let request = URLRequest(url: url) let request = URLRequest(url: url)
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
if let error = error { if let error = error {
completion(.failure(.networkError(error))) completion(.failure(error))
return return
} }
guard let data = data, guard let data = data,
let response = response as? HTTPURLResponse else { let response = response as? HTTPURLResponse else {
completion(.failure(.invalidResponse)) completion(.failure(Client.Error.invalidResponse))
return return
} }
guard response.statusCode == 200 else { guard response.statusCode == 200 else {
completion(.failure(.unexpectedStatus(response.statusCode))) completion(.failure(Client.Error.unknownError))
return return
} }
guard let result = try? decoder.decode([Instance].self, from: data) else { guard let result = try? decoder.decode([Instance].self, from: data) else {

View File

@ -9,14 +9,14 @@
import Foundation import Foundation
public class NotificationGroup { public class NotificationGroup {
public let notifications: [Notification] public let notificationIDs: [String]
public let id: String public let id: String
public let kind: Notification.Kind public let kind: Notification.Kind
public let statusState: StatusState? public let statusState: StatusState?
init?(notifications: [Notification]) { init?(notifications: [Notification]) {
guard !notifications.isEmpty else { return nil } guard !notifications.isEmpty else { return nil }
self.notifications = notifications self.notificationIDs = notifications.map { $0.id }
self.id = notifications.first!.id self.id = notifications.first!.id
self.kind = notifications.first!.kind self.kind = notifications.first!.kind
if kind == .mention { if kind == .mention {
@ -27,24 +27,18 @@ public class NotificationGroup {
} }
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] { public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
var groups = [[Notification]]() return notifications.reduce(into: [[Notification]]()) { (groups, notification) in
for notification in notifications { if allowedTypes.contains(notification.kind),
if allowedTypes.contains(notification.kind) { let lastGroup = groups.last,
if let lastGroup = groups.last, let firstNotification = lastGroup.first, firstNotification.kind == notification.kind, firstNotification.status?.id == notification.status?.id { let firstStatus = lastGroup.first,
groups[groups.count - 1].append(notification) firstStatus.kind == notification.kind,
continue firstStatus.status?.id == notification.status?.id {
} else if groups.count >= 2 {
let secondToLastGroup = groups[groups.count - 2] groups[groups.count - 1].append(notification)
if allowedTypes.contains(groups[groups.count - 1][0].kind), let firstNotification = secondToLastGroup.first, firstNotification.kind == notification.kind, firstNotification.status?.id == notification.status?.id { } else {
groups[groups.count - 2].append(notification) groups.append([notification])
continue
}
}
} }
}.map {
groups.append([notification])
}
return groups.map {
NotificationGroup(notifications: $0)! NotificationGroup(notifications: $0)!
} }
} }

View File

@ -0,0 +1,126 @@
//
// PromiseTests.swift
// PachydermTests
//
// Created by Shadowfacts on 2/14/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import XCTest
@testable import Pachyderm
class PromiseTests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func assertResultEqual<Result: Equatable>(_ promise: Promise<Result>, _ value: Result, message: String? = nil) {
let expectation = self.expectation(description: message ?? "promise result assertion")
promise.then {
XCTAssertEqual($0, value)
expectation.fulfill()
}
self.waitForExpectations(timeout: 2) { (error) in
if let error = error {
XCTFail("didn't resolve promise: \(error)")
}
}
}
func testResolveImmediate() {
assertResultEqual(Promise<String>.resolve("blah"), "blah")
}
func testResolveImmediateMapped() {
let promise = Promise<String>.resolve("foo").then {
"test \($0)"
}.then {
Promise<String>.resolve("\($0) bar")
}
assertResultEqual(promise, "test foo bar")
}
func testContinueAfterReject() {
let promise = Promise<String>.reject(TestError()).then { (res) in
XCTFail("then on rejected promise is unreachable")
}.catch { (error) -> String in
XCTAssertTrue(error is TestError)
return "caught"
}.then {
"\($0) error"
}
assertResultEqual(promise, "caught error")
}
func testResolveDelayed() {
let promise = Promise<String> { (resolve, reject) in
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
resolve("blah")
}
}
assertResultEqual(promise, "blah")
}
func testResolveMappedDelayed() {
let promise = Promise<String> { (resolve, reject) in
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
resolve("foo")
}
}.then {
"\($0) bar"
}.then { (result) in
Promise<String> { (resolve, reject) in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
resolve("\(result) baz")
}
}
}
assertResultEqual(promise, "foo bar baz")
}
func testResolveAll() {
let promise = Promise<[String]>.all([
Promise<String>.resolve("a"),
Promise<String>.resolve("b"),
Promise<String>.resolve("c"),
])
assertResultEqual(promise, ["a", "b", "c"])
}
func testIntermediateReject() {
let promise = Promise<String>.resolve("foo").then { (_) -> Promise<String> in
Promise<String>.reject(TestError())
}.catch { (error) -> String in
XCTAssertTrue(error is TestError)
return "caught"
}.then { (result) -> String in
"\(result) error"
}
assertResultEqual(promise, "caught error")
}
func testResultHelper() {
let success = Promise<String> { (handler) in
handler(Result<String, Never>.success("asdf"))
}
assertResultEqual(success, "asdf")
let failure = Promise<String> { (handler) in
handler(Result<String, TestError>.failure(TestError()))
}.catch { (error) -> String in
"blah"
}
assertResultEqual(failure, "blah")
}
}
struct TestError: Error {
var localizedDescription: String {
"test error"
}
}

1
SwiftSoup Submodule

@ -0,0 +1 @@
Subproject commit f445c9067d28346e828e615e2b43cb07b20bca35

File diff suppressed because it is too large Load Diff

View File

@ -27,6 +27,15 @@
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"> shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D6D4DDCB212518A000E1C4BB"
BuildableName = "Tusker.app"
BlueprintName = "Tusker"
ReferencedContainer = "container:Tusker.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables> <Testables>
<TestableReference <TestableReference
skipped = "NO"> skipped = "NO">
@ -83,12 +92,6 @@
ReferencedContainer = "container:Tusker.xcodeproj"> ReferencedContainer = "container:Tusker.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"

View File

@ -10,6 +10,9 @@
<FileRef <FileRef
location = "group:Cache/Cache.xcodeproj"> location = "group:Cache/Cache.xcodeproj">
</FileRef> </FileRef>
<FileRef
location = "group:SwiftSoup/SwiftSoup.xcodeproj">
</FileRef>
<FileRef <FileRef
location = "group:Gifu/Gifu.xcodeproj"> location = "group:Gifu/Gifu.xcodeproj">
</FileRef> </FileRef>

View File

@ -1,32 +1,14 @@
{ {
"object": { "object": {
"pins": [ "pins": [
{
"package": "PLCrashReporter",
"repositoryURL": "https://github.com/microsoft/plcrashreporter",
"state": {
"branch": null,
"revision": "6b7ca9a2faad6ea990ff60b0a3ee4fdf3db59150",
"version": "1.7.2"
}
},
{ {
"package": "SheetController", "package": "SheetController",
"repositoryURL": "https://git.shadowfacts.net/shadowfacts/SheetController.git", "repositoryURL": "https://git.shadowfacts.net/shadowfacts/SheetController.git",
"state": { "state": {
"branch": "master", "branch": "master",
"revision": "aa0f5192eaf19d01c89dbfa9ec5878a700376f23", "revision": "6ee1ad24ec8620f5c17416d6141643f0787708ba",
"version": null "version": null
} }
},
{
"package": "SwiftSoup",
"repositoryURL": "https://github.com/scinfu/SwiftSoup.git",
"state": {
"branch": null,
"revision": "774dc9c7213085db8aa59595e27c1cd22e428904",
"version": "2.3.2"
}
} }
] ]
}, },

View File

@ -7,6 +7,7 @@
// //
import UIKit import UIKit
import Pachyderm
class AccountActivity: MastodonActivity { class AccountActivity: MastodonActivity {
@ -14,17 +15,17 @@ class AccountActivity: MastodonActivity {
return .action return .action
} }
var account: AccountMO? var account: Account?
override func canPerform(withActivityItems activityItems: [Any]) -> Bool { override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
for case is AccountMO in activityItems { for case is Account in activityItems {
return true return true
} }
return false return false
} }
override func prepare(withActivityItems activityItems: [Any]) { override func prepare(withActivityItems activityItems: [Any]) {
for case let account as AccountMO in activityItems { for case let account as Account in activityItems {
self.account = account self.account = account
return return
} }

View File

@ -28,12 +28,11 @@ class FollowAccountActivity: AccountActivity {
UIImpactFeedbackGenerator(style: .medium).impactOccurred() UIImpactFeedbackGenerator(style: .medium).impactOccurred()
let request = Account.follow(account.id) let request = Account.follow(account.id)
mastodonController.run(request) { (response) in mastodonController.run(request).then { (relationship, _) -> Void in
if case .failure(_) = response { self.mastodonController.cache.add(relationship: relationship)
// todo: display error message }.catch { (error) -> Void in
UINotificationFeedbackGenerator().notificationOccurred(.error) print("could not follow account")
fatalError() UINotificationFeedbackGenerator().notificationOccurred(.error)
}
} }
} }

View File

@ -28,9 +28,7 @@ class SendMessageActivity: AccountActivity {
override var activityViewController: UIViewController? { override var activityViewController: UIViewController? {
guard let account = account else { return nil } guard let account = account else { return nil }
let draft = mastodonController.createDraft(mentioningAcct: account.acct) return UINavigationController(rootViewController: ComposeViewController(mentioningAcct: account.acct, mastodonController: mastodonController))
let compose = ComposeHostingController(draft: draft, mastodonController: mastodonController)
return UINavigationController(rootViewController: compose)
} }
} }

View File

@ -28,12 +28,11 @@ class UnfollowAccountActivity: AccountActivity {
UIImpactFeedbackGenerator(style: .medium).impactOccurred() UIImpactFeedbackGenerator(style: .medium).impactOccurred()
let request = Account.unfollow(account.id) let request = Account.unfollow(account.id)
mastodonController.run(request) { (response) in mastodonController.run(request).then { (relationship, _) -> Void in
if case .failure(_) = response { self.mastodonController.cache.add(relationship: relationship)
// todo: display error message }.catch { (error) -> Void in
UINotificationFeedbackGenerator().notificationOccurred(.error) print("could not unfollow account: \(error)")
fatalError() UINotificationFeedbackGenerator().notificationOccurred(.error)
}
} }
} }

View File

@ -1,39 +0,0 @@
//
// AccountActivityItemSource.swift
// Tusker
//
// Created by Shadowfacts on 5/14/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import LinkPresentation
class AccountActivityItemSource: NSObject, UIActivityItemSource {
let account: AccountMO
init(_ account: AccountMO) {
self.account = account
}
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
return account
}
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
return account
}
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
let metadata = LPLinkMetadata()
metadata.originalURL = account.url
metadata.url = account.url
metadata.title = "\(account.displayName) (@\(account.username)@\(account.url.host!)"
if let data = ImageCache.avatars.get(account.avatar),
let image = UIImage(data: data) {
metadata.iconProvider = NSItemProvider(object: image)
}
return metadata
}
}

View File

@ -36,10 +36,10 @@ class OpenInSafariActivity: UIActivity {
activityDidFinish(true) activityDidFinish(true)
} }
static func completionHandler(navigator: TuskerNavigationDelegate, url: URL) -> UIActivityViewController.CompletionWithItemsHandler { static func completionHandler(viewController: UIViewController, url: URL) -> UIActivityViewController.CompletionWithItemsHandler {
return { (activityType, _, _, _) in return { (activityType, _, _, _) in
if activityType == .openInSafari { if activityType == .openInSafari {
navigator.show(SFSafariViewController(url: url)) viewController.present(SFSafariViewController(url: url), animated: true)
} }
} }
} }

View File

@ -26,15 +26,12 @@ class BookmarkStatusActivity: StatusActivity {
override func perform() { override func perform() {
guard let status = status else { return } guard let status = status else { return }
let request = Status.bookmark(status.id) let request = Status.bookmark(status)
mastodonController.run(request) { (response) in mastodonController.run(request).then { (status, _) -> Void in
if case let .success(status, _) = response { self.mastodonController.cache.add(status: status)
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false) }.catch { (error) -> Void in
} else { print("could not bookmark status: \(error)")
// todo: display error message UINotificationFeedbackGenerator().notificationOccurred(.error)
UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError()
}
} }
} }

View File

@ -1,40 +0,0 @@
//
// MuteConversationActivity.swift
// Tusker
//
// Created by Shadowfacts on 5/14/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class MuteConversationActivity: StatusActivity {
override var activityType: UIActivity.ActivityType? {
return .muteConversation
}
override var activityTitle: String? {
return NSLocalizedString("Mute Conversation", comment: "mute conversation activity title")
}
override var activityImage: UIImage? {
return UIImage(systemName: "speaker.slash")
}
override func perform() {
guard let status = status else { return }
let request = Status.muteConversation(status.id)
mastodonController.run(request) { (response) in
if case let .success(status, _) = response {
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
} else {
// todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError()
}
}
}
}

View File

@ -25,15 +25,12 @@ class PinStatusActivity: StatusActivity {
override func perform() { override func perform() {
guard let status = status else { return } guard let status = status else { return }
let request = Status.pin(status.id) let request = Status.pin(status)
mastodonController.run(request) { (response) in mastodonController.run(request).then { (status, _) -> Void in
if case let .success(status, _) = response { self.mastodonController.cache.add(status: status)
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false) }.catch { (error) -> Void in
} else { print("could not pin status: \(error)")
// todo: display error message UINotificationFeedbackGenerator().notificationOccurred(.error)
UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError()
}
} }
} }
} }

View File

@ -7,6 +7,7 @@
// //
import UIKit import UIKit
import Pachyderm
class StatusActivity: MastodonActivity { class StatusActivity: MastodonActivity {
@ -14,17 +15,17 @@ class StatusActivity: MastodonActivity {
return .action return .action
} }
var status: StatusMO? var status: Status?
override func canPerform(withActivityItems activityItems: [Any]) -> Bool { override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
for case is StatusMO in activityItems { for case is Status in activityItems {
return true return true
} }
return false return false
} }
override func prepare(withActivityItems activityItems: [Any]) { override func prepare(withActivityItems activityItems: [Any]) {
for case let status as StatusMO in activityItems { for case let status as Status in activityItems {
self.status = status self.status = status
return return
} }

View File

@ -26,15 +26,12 @@ class UnbookmarkStatusActivity: StatusActivity {
override func perform() { override func perform() {
guard let status = status else { return } guard let status = status else { return }
let request = Status.unbookmark(status.id) let request = Status.unbookmark(status)
mastodonController.run(request) { (response) in mastodonController.run(request).then { (status, _) -> Void in
if case let .success(status, _) = response { self.mastodonController.cache.add(status: status)
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false) }.catch { (error) -> Void in
} else { print("could not unbookmark status: \(error)")
// todo: display error message UINotificationFeedbackGenerator().notificationOccurred(.error)
UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError()
}
} }
} }

View File

@ -1,40 +0,0 @@
//
// UnmuteConversationActivity.swift
// Tusker
//
// Created by Shadowfacts on 5/14/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class UnmuteConversationActivity: StatusActivity {
override var activityType: UIActivity.ActivityType? {
return .unmuteConversation
}
override var activityTitle: String? {
return NSLocalizedString("Unmute Conversation", comment: "unmute conversation activity title")
}
override var activityImage: UIImage? {
return UIImage(systemName: "speaker")
}
override func perform() {
guard let status = status else { return }
let request = Status.unmuteConversation(status.id)
mastodonController.run(request) { (response) in
if case let .success(status, _) = response {
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
} else {
// todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError()
}
}
}
}

View File

@ -25,15 +25,12 @@ class UnpinStatusActivity: StatusActivity {
override func perform() { override func perform() {
guard let status = status else { return } guard let status = status else { return }
let request = Status.unpin(status.id) let request = Status.unpin(status)
mastodonController.run(request) { (response) in mastodonController.run(request).then { (status, _) -> Void in
if case let .success(status, _) = response { self.mastodonController.cache.add(status: status)
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false) }.catch { (error) -> Void in
} else { print("could not unpin status: \(error)")
// todo: display error message UINotificationFeedbackGenerator().notificationOccurred(.error)
UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError()
}
} }
} }
} }

View File

@ -1,42 +0,0 @@
//
// StatusActivityItemSource.swift
// Tusker
//
// Created by Shadowfacts on 5/14/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import LinkPresentation
import SwiftSoup
class StatusActivityItemSource: NSObject, UIActivityItemSource {
let status: StatusMO
init(_ status: StatusMO) {
self.status = status
}
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
return status
}
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
return status
}
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
let metadata = LPLinkMetadata()
metadata.originalURL = status.url!
metadata.url = status.url!
let doc = try! SwiftSoup.parse(status.content)
let content = try! doc.text()
metadata.title = "\(status.account.displayName): \"\(content)\""
if let data = ImageCache.avatars.get(status.account.avatar),
let image = UIImage(data: data) {
metadata.iconProvider = NSItemProvider(object: image)
}
return metadata
}
}

View File

@ -22,7 +22,5 @@ extension UIActivity.ActivityType {
static let unbookmarkStatus = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).unbookmark_status") static let unbookmarkStatus = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).unbookmark_status")
static let pinStatus = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).pin_status") static let pinStatus = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).pin_status")
static let unpinStatus = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).unpin_status") static let unpinStatus = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).unpin_status")
static let muteConversation = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).mute_conversation")
static let unmuteConversation = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).unmute_conversation")
} }

View File

@ -7,41 +7,13 @@
// //
import UIKit import UIKit
import CrashReporter
@UIApplicationMain @UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
static private(set) var crashReporter: PLCrashReporter!
static var pendingCrashReport: PLCrashReport?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
#if !DEBUG
setupCrashReporter()
#endif
AppShortcutItem.createItems(for: application) AppShortcutItem.createItems(for: application)
DispatchQueue.global(qos: .userInitiated).async {
AudioSessionHelper.disable()
AudioSessionHelper.setDefault()
}
return true return true
} }
private func setupCrashReporter() {
let config = PLCrashReporterConfig(signalHandlerType: .BSD, symbolicationStrategy: .all)
AppDelegate.crashReporter = PLCrashReporter(configuration: config)
if AppDelegate.crashReporter.hasPendingCrashReport() {
let data = try! AppDelegate.crashReporter.loadPendingCrashReportDataAndReturnError()
AppDelegate.crashReporter.purgePendingCrashReport()
let report = try! PLCrashReport(data: data)
AppDelegate.pendingCrashReport = report
}
AppDelegate.crashReporter.enable()
}
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 580 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

View File

@ -1,116 +1,98 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "20x20@2x.png",
"idiom" : "iphone", "idiom" : "iphone",
"scale" : "2x", "size" : "20x20",
"size" : "20x20" "scale" : "2x"
}, },
{ {
"filename" : "20x20@3x.png",
"idiom" : "iphone", "idiom" : "iphone",
"scale" : "3x", "size" : "20x20",
"size" : "20x20" "scale" : "3x"
}, },
{ {
"filename" : "29x29@2x.png",
"idiom" : "iphone", "idiom" : "iphone",
"scale" : "2x", "size" : "29x29",
"size" : "29x29" "scale" : "2x"
}, },
{ {
"filename" : "29x29@3x.png",
"idiom" : "iphone", "idiom" : "iphone",
"scale" : "3x", "size" : "29x29",
"size" : "29x29" "scale" : "3x"
}, },
{ {
"filename" : "40x40@2x.png",
"idiom" : "iphone", "idiom" : "iphone",
"scale" : "2x", "size" : "40x40",
"size" : "40x40" "scale" : "2x"
}, },
{ {
"filename" : "40x40@3x.png",
"idiom" : "iphone", "idiom" : "iphone",
"scale" : "3x", "size" : "40x40",
"size" : "40x40" "scale" : "3x"
}, },
{ {
"filename" : "60x60@2x.png",
"idiom" : "iphone", "idiom" : "iphone",
"scale" : "2x", "size" : "60x60",
"size" : "60x60" "scale" : "2x"
}, },
{ {
"filename" : "60x60@3x.png",
"idiom" : "iphone", "idiom" : "iphone",
"scale" : "3x", "size" : "60x60",
"size" : "60x60" "scale" : "3x"
}, },
{ {
"filename" : "20x20@1x.png",
"idiom" : "ipad", "idiom" : "ipad",
"scale" : "1x", "size" : "20x20",
"size" : "20x20" "scale" : "1x"
}, },
{ {
"filename" : "20x20@2x-1.png",
"idiom" : "ipad", "idiom" : "ipad",
"scale" : "2x", "size" : "20x20",
"size" : "20x20" "scale" : "2x"
}, },
{ {
"filename" : "29x29@1x.png",
"idiom" : "ipad", "idiom" : "ipad",
"scale" : "1x", "size" : "29x29",
"size" : "29x29" "scale" : "1x"
}, },
{ {
"filename" : "29x29@2x-1.png",
"idiom" : "ipad", "idiom" : "ipad",
"scale" : "2x", "size" : "29x29",
"size" : "29x29" "scale" : "2x"
}, },
{ {
"filename" : "40x40@1x.png",
"idiom" : "ipad", "idiom" : "ipad",
"scale" : "1x", "size" : "40x40",
"size" : "40x40" "scale" : "1x"
}, },
{ {
"filename" : "40x40@2x-1.png",
"idiom" : "ipad", "idiom" : "ipad",
"scale" : "2x", "size" : "40x40",
"size" : "40x40" "scale" : "2x"
}, },
{ {
"filename" : "76x76@1x.png",
"idiom" : "ipad", "idiom" : "ipad",
"scale" : "1x", "size" : "76x76",
"size" : "76x76" "scale" : "1x"
}, },
{ {
"filename" : "76x76@2x.png",
"idiom" : "ipad", "idiom" : "ipad",
"scale" : "2x", "size" : "76x76",
"size" : "76x76" "scale" : "2x"
}, },
{ {
"filename" : "83.5x83.5@2x.png",
"idiom" : "ipad", "idiom" : "ipad",
"scale" : "2x", "size" : "83.5x83.5",
"size" : "83.5x83.5" "scale" : "2x"
}, },
{ {
"filename" : "1024x1024@1x.png",
"idiom" : "ios-marketing", "idiom" : "ios-marketing",
"scale" : "1x", "size" : "1024x1024",
"size" : "1024x1024" "scale" : "1x"
} }
], ],
"info" : { "info" : {
"author" : "xcode", "version" : 1,
"version" : 1 "author" : "xcode"
} }
} }

View File

@ -1,28 +0,0 @@
//
// AudioSessionHelper.swift
// Tusker
//
// Created by Shadowfacts on 6/21/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
import AVFoundation
struct AudioSessionHelper {
static func enable() {
try? AVAudioSession.sharedInstance().setActive(true, options: [])
}
static func disable() {
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
static func setDefault() {
try? AVAudioSession.sharedInstance().setCategory(.playback, options: .mixWithOthers)
}
static func setVideoPlayback() {
try? AVAudioSession.sharedInstance().setCategory(.playback, options: [])
}
}

View File

@ -1,35 +0,0 @@
//
// CachedDictionary.swift
// Tusker
//
// Created by Shadowfacts on 5/6/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
class CachedDictionary<Value> {
private let name: String
private var dict = [String: Value]()
private let queue: DispatchQueue
init(name: String) {
self.name = name
self.queue = DispatchQueue(label: "CachedDictionary (\(name)) Coordinator", attributes: .concurrent)
}
subscript(key: String) -> Value? {
get {
var result: Value? = nil
queue.sync {
result = dict[key]
}
return result
}
set(value) {
queue.async(flags: .barrier) {
self.dict[key] = value
}
}
}
}

View File

@ -47,15 +47,4 @@ enum Cache<T> {
try hybrid.setObject(object, forKey: key, expiry: expiry) 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()
}
}
} }

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Cache import Cache
import Pachyderm
class ImageCache { class ImageCache {
@ -16,9 +17,9 @@ class ImageCache {
static let attachments = ImageCache(name: "Attachments", memoryExpiry: .seconds(60 * 2)) static let attachments = ImageCache(name: "Attachments", memoryExpiry: .seconds(60 * 2))
static let emojis = ImageCache(name: "Emojis", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60)) static let emojis = ImageCache(name: "Emojis", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60))
private let cache: Cache<Data> let cache: Cache<Data>
private var groups = [URL: RequestGroup]() var requests = [URL: RequestGroup]()
init(name: String, memoryExpiry expiry: Expiry) { init(name: String, memoryExpiry expiry: Expiry) {
let storage = MemoryStorage<Data>(config: MemoryConfig(expiry: expiry)) let storage = MemoryStorage<Data>(config: MemoryConfig(expiry: expiry))
@ -43,56 +44,59 @@ class ImageCache {
completion?(data) completion?(data)
return nil return nil
} else { } else {
if let completion = completion, let group = groups[url] { if let completion = completion, let group = requests[url] {
return group.addCallback(completion) return group.addCallback(completion)
} else { } else {
let group = RequestGroup(url: url) { (data) in let group = RequestGroup(url: url)
if let data = data {
try? self.cache.setObject(data, forKey: key)
}
self.groups.removeValue(forKey: url)
}
groups[url] = group
let request = group.addCallback(completion) let request = group.addCallback(completion)
group.run() group.run { (data) in
try? self.cache.setObject(data, forKey: key)
}
return request return request
} }
} }
} }
func get(_ url: URL) -> Promise<Data> {
return Promise<Data> { (resolve, reject) in
_ = self.get(url) { (data) in
if let data = data {
resolve(data)
} else {
reject(Error.unknown)
}
}
}
}
func get(_ url: URL) -> Data? { func get(_ url: URL) -> Data? {
return try? cache.object(forKey: url.absoluteString) return try? cache.object(forKey: url.absoluteString)
} }
func cancelWithoutCallback(_ url: URL) { func cancelWithoutCallback(_ url: URL) {
groups[url]?.cancelWithoutCallback() requests[url]?.cancelWithoutCallback()
} }
func reset() throws { class RequestGroup {
try cache.removeAll()
}
private class RequestGroup {
let url: URL let url: URL
private let onFinished: (Data?) -> Void
private var task: URLSessionDataTask? private var task: URLSessionDataTask?
private var requests = [Request]() private var requests = [Request]()
init(url: URL, onFinished: @escaping (Data?) -> Void) { init(url: URL) {
self.url = url self.url = url
self.onFinished = onFinished
} }
deinit { deinit {
task?.cancel() task?.cancel()
} }
func run() { func run(cache: @escaping (Data) -> Void) {
task = URLSession.shared.dataTask(with: url, completionHandler: { (data, response, error) in task = URLSession.shared.dataTask(with: url, completionHandler: { (data, response, error) in
guard error == nil, let data = data else { guard error == nil, let data = data else {
self.complete(with: nil) self.complete(with: nil)
return return
} }
cache(data)
self.complete(with: data) self.complete(with: data)
}) })
task!.resume() task!.resume()
@ -132,12 +136,11 @@ class ImageCache {
callback(data) callback(data)
} }
} }
self.onFinished(data)
} }
} }
class Request { class Request {
private weak var group: RequestGroup? weak var group: RequestGroup?
private(set) var callback: ((Data?) -> Void)? private(set) var callback: ((Data?) -> Void)?
private(set) var cancelled: Bool = false private(set) var cancelled: Bool = false
@ -152,4 +155,8 @@ class ImageCache {
} }
} }
enum Error: Swift.Error {
case unknown
}
} }

View File

@ -8,6 +8,7 @@
import Foundation import Foundation
import Pachyderm import Pachyderm
import Combine
class MastodonController { class MastodonController {
@ -30,36 +31,34 @@ class MastodonController {
} }
} }
static func resetAll() { private(set) lazy var cache = MastodonCache(mastodonController: self)
all = [:]
}
private let transient: Bool
private(set) lazy var persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient)
let instanceURL: URL let instanceURL: URL
var accountInfo: LocalData.UserAccountInfo? private(set) var accountInfo: LocalData.UserAccountInfo?
let client: Client! let client: Client!
var account: Account! var account: Account!
var instance: Instance! var instance: Instance!
var loggedIn: Bool {
accountInfo != nil
}
init(instanceURL: URL, transient: Bool = false) { init(instanceURL: URL) {
self.instanceURL = instanceURL self.instanceURL = instanceURL
self.accountInfo = nil self.accountInfo = nil
self.client = Client(baseURL: instanceURL) self.client = Client(baseURL: instanceURL)
self.transient = transient
} }
func run<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) { func run<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) {
client.run(request, completion: completion) client.run(request, completion: completion)
} }
func run<Result: Decodable>(_ request: Request<Result>) -> Promise<(Result, Pagination?)> {
return client.run(request)
}
func run<Result: Decodable>(_ request: Request<Result>) -> AnyPublisher<(Result, Pagination?), Error> {
return client.run(request)
}
func registerApp(completion: @escaping (_ clientID: String, _ clientSecret: String) -> Void) { func registerApp(completion: @escaping (_ clientID: String, _ clientSecret: String) -> Void) {
guard client.clientID == nil, guard client.clientID == nil,
client.clientSecret == nil else { client.clientSecret == nil else {
@ -84,47 +83,28 @@ class MastodonController {
} }
} }
func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) { func getOwnAccount(completion: ((Account) -> Void)? = nil) {
if account != nil { if account != nil {
completion?(.success(account)) completion?(account)
} else { } else {
let request = Client.getSelfAccount() let request = Client.getSelfAccount()
run(request) { response in run(request).then { (account, _) -> Void in
switch response { self.account = account
case let .failure(error): self.cache.add(account: account)
completion?(.failure(error)) completion?(account)
}.catch { (error) -> Void in
case let .success(account, _): fatalError("couldn't get own account: \(error)")
self.account = account
self.persistentContainer.backgroundContext.perform {
if let accountMO = self.persistentContainer.account(for: account.id, in: self.persistentContainer.backgroundContext) {
accountMO.updateFrom(apiAccount: account, container: self.persistentContainer)
} else {
// the first time the user's account is added to the store,
// increment its reference count so that it's never removed
self.persistentContainer.addOrUpdate(account: account, incrementReferenceCount: true)
}
completion?(.success(account))
}
}
} }
} }
} }
func getOwnInstance(completion: ((Instance) -> Void)? = nil) { func getOwnInstance() {
if let instance = self.instance { let request = Client.getInstance()
completion?(instance) run(request).then { (instance, _) -> Void in
} else { self.instance = instance
let request = Client.getInstance() }.catch { (error) -> Void in
run(request) { (response) in fatalError("couldn't get own instance: \(error)")
guard case let .success(instance, _) = response else { fatalError() }
self.instance = instance
completion?(instance)
}
} }
} }
} }
// ObservableObject so that SwiftUI views can receive it through @EnvironmentObject
extension MastodonController: ObservableObject {}

View File

@ -1,105 +0,0 @@
//
// AccountMO.swift
// Tusker
//
// Created by Shadowfacts on 4/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
//
import Foundation
import CoreData
import Pachyderm
@objc(AccountMO)
public final class AccountMO: NSManagedObject, AccountProtocol {
@nonobjc public class func fetchRequest() -> NSFetchRequest<AccountMO> {
return NSFetchRequest<AccountMO>(entityName: "Account")
}
@NSManaged public var acct: String
@NSManaged public var avatar: URL
@NSManaged public var botCD: Bool
@NSManaged public var createdAt: Date
@NSManaged public var displayName: String
@NSManaged private var emojisData: Data?
@NSManaged private var fieldsData: Data?
@NSManaged public var followersCount: Int
@NSManaged public var followingCount: Int
@NSManaged public var header: URL
@NSManaged public var id: String
@NSManaged public var locked: Bool
@NSManaged public var movedCD: Bool
@NSManaged public var note: String
@NSManaged public var referenceCount: Int
@NSManaged public var statusesCount: Int
@NSManaged public var url: URL
@NSManaged public var username: String
@NSManaged public var movedTo: AccountMO?
@LazilyDecoding(arrayFrom: \AccountMO.emojisData)
public var emojis: [Emoji]
@LazilyDecoding(arrayFrom: \AccountMO.fieldsData)
public var fields: [Pachyderm.Account.Field]
public var bot: Bool? { botCD }
public var moved: Bool? { movedCD }
func incrementReferenceCount() {
referenceCount += 1
}
func decrementReferenceCount() {
referenceCount -= 1
if referenceCount <= 0 {
managedObjectContext!.delete(self)
}
}
public override func prepareForDeletion() {
super.prepareForDeletion()
movedTo?.decrementReferenceCount()
}
}
extension AccountMO {
convenience init(apiAccount account: Pachyderm.Account, container: MastodonCachePersistentStore, context: NSManagedObjectContext) {
self.init(context: context)
self.updateFrom(apiAccount: account, container: container)
movedTo?.incrementReferenceCount()
}
func updateFrom(apiAccount account: Pachyderm.Account, container: MastodonCachePersistentStore) {
guard let context = managedObjectContext else {
// we've been deleted, don't bother updating
return
}
self.acct = account.acct
self.avatar = account.avatarStatic // we don't animate avatars
self.botCD = account.bot ?? false
self.createdAt = account.createdAt
self.displayName = account.displayName
self.emojis = account.emojis
self.fields = account.fields
self.followersCount = account.followersCount
self.followingCount = account.followingCount
self.header = account.headerStatic // we don't animate headers
self.id = account.id
self.locked = account.locked
self.movedCD = account.moved ?? false
self.note = account.note
self.statusesCount = account.statusesCount
self.url = account.url
self.username = account.username
if let movedTo = account.movedTo {
self.movedTo = container.account(for: movedTo.id, in: context) ?? AccountMO(apiAccount: movedTo, container: container, context: context)
} else {
self.movedTo = nil
}
}
}

View File

@ -1,190 +0,0 @@
//
// MastodonCachePersistentStore.swift
// Tusker
//
// Created by Shadowfacts on 4/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
import CoreData
import Pachyderm
import Combine
class MastodonCachePersistentStore: NSPersistentContainer {
private static let managedObjectModel: NSManagedObjectModel = {
let url = Bundle.main.url(forResource: "Tusker", withExtension: "momd")!
return NSManagedObjectModel(contentsOf: url)!
}()
private(set) lazy var backgroundContext: NSManagedObjectContext = {
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
context.parent = self.viewContext
return context
}()
let statusSubject = PassthroughSubject<String, Never>()
let accountSubject = PassthroughSubject<String, Never>()
init(for accountInfo: LocalData.UserAccountInfo?, transient: Bool = false) {
if transient {
super.init(name: "transient_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
let storeDescription = NSPersistentStoreDescription()
storeDescription.type = NSInMemoryStoreType
persistentStoreDescriptions = [storeDescription]
} else {
super.init(name: "\(accountInfo!.id)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
}
loadPersistentStores { (description, error) in
if let error = error {
fatalError("Unable to load persistent store: \(error)")
}
}
}
func status(for id: String, in context: NSManagedObjectContext? = nil) -> StatusMO? {
let context = context ?? viewContext
let request: NSFetchRequest<StatusMO> = StatusMO.fetchRequest()
request.predicate = NSPredicate(format: "id = %@", id)
request.fetchLimit = 1
if let result = try? context.fetch(request), let status = result.first {
return status
} else {
return nil
}
}
@discardableResult
private func upsert(status: Status, incrementReferenceCount: Bool) -> StatusMO {
if let statusMO = self.status(for: status.id, in: self.backgroundContext) {
statusMO.updateFrom(apiStatus: status, container: self)
if incrementReferenceCount {
statusMO.incrementReferenceCount()
}
return statusMO
} else {
let statusMO = StatusMO(apiStatus: status, container: self, context: self.backgroundContext)
if incrementReferenceCount {
statusMO.incrementReferenceCount()
}
return statusMO
}
}
func addOrUpdate(status: Status, incrementReferenceCount: Bool, completion: ((StatusMO) -> Void)? = nil) {
backgroundContext.perform {
let statusMO = self.upsert(status: status, incrementReferenceCount: incrementReferenceCount)
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
completion?(statusMO)
self.statusSubject.send(status.id)
}
}
func addAll(statuses: [Status], completion: (() -> Void)? = nil) {
backgroundContext.perform {
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true) }
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
statuses.forEach { self.statusSubject.send($0.id) }
completion?()
}
}
func account(for id: String, in context: NSManagedObjectContext? = nil) -> AccountMO? {
let context = context ?? viewContext
let request: NSFetchRequest<AccountMO> = AccountMO.fetchRequest()
request.predicate = NSPredicate(format: "id = %@", id)
request.fetchLimit = 1
if let result = try? context.fetch(request), let account = result.first {
return account
} else {
return nil
}
}
@discardableResult
private func upsert(account: Account, incrementReferenceCount: Bool) -> AccountMO {
if let accountMO = self.account(for: account.id, in: self.backgroundContext) {
accountMO.updateFrom(apiAccount: account, container: self)
if incrementReferenceCount {
accountMO.incrementReferenceCount()
}
return accountMO
} else {
let accountMO = AccountMO(apiAccount: account, container: self, context: self.backgroundContext)
if incrementReferenceCount {
accountMO.incrementReferenceCount()
}
return accountMO
}
}
func addOrUpdate(account: Account, incrementReferenceCount: Bool, completion: ((AccountMO) -> Void)? = nil) {
backgroundContext.perform {
let accountMO = self.upsert(account: account, incrementReferenceCount: incrementReferenceCount)
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
completion?(accountMO)
self.accountSubject.send(account.id)
}
}
func addAll(accounts: [Account], completion: (() -> Void)? = nil) {
backgroundContext.perform {
accounts.forEach { self.upsert(account: $0, incrementReferenceCount: true) }
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
completion?()
accounts.forEach { self.accountSubject.send($0.id) }
}
}
func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) {
backgroundContext.perform {
let statuses = notifications.compactMap { $0.status }
// filter out mentions, otherwise we would double increment the reference count of those accounts
// since the status has the same account as the notification
let accounts = notifications.filter { $0.kind != .mention }.map { $0.account }
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true) }
accounts.forEach { self.upsert(account: $0, incrementReferenceCount: true) }
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
completion?()
statuses.forEach { self.statusSubject.send($0.id) }
accounts.forEach { self.accountSubject.send($0.id) }
}
}
func performBatchUpdates(_ block: @escaping (_ context: NSManagedObjectContext, _ addAccounts: ([Account]) -> Void, _ addStatuses: ([Status]) -> Void) -> Void, completion: (() -> Void)? = nil) {
backgroundContext.perform {
var updatedAccounts = [String]()
var updatedStatuses = [String]()
block(self.backgroundContext, { (accounts) in
accounts.forEach { self.upsert(account: $0, incrementReferenceCount: true) }
updatedAccounts.append(contentsOf: accounts.map { $0.id })
}, { (statuses) in
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true) }
updatedStatuses.append(contentsOf: statuses.map { $0.id })
})
updatedAccounts.forEach(self.accountSubject.send)
updatedStatuses.forEach(self.statusSubject.send)
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
completion?()
}
}
}

View File

@ -1,144 +0,0 @@
//
// StatusMO.swift
// Tusker
//
// Created by Shadowfacts on 4/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
//
import Foundation
import CoreData
import Pachyderm
@objc(StatusMO)
public final class StatusMO: NSManagedObject, StatusProtocol {
@nonobjc public class func fetchRequest() -> NSFetchRequest<StatusMO> {
return NSFetchRequest<StatusMO>(entityName: "Status")
}
@NSManaged public var applicationName: String?
@NSManaged private var attachmentsData: Data?
@NSManaged private var bookmarkedInternal: Bool
@NSManaged public var content: String
@NSManaged public var createdAt: Date
@NSManaged private var emojisData: Data?
@NSManaged public var favourited: Bool
@NSManaged public var favouritesCount: Int
@NSManaged private var hashtagsData: Data?
@NSManaged public var id: String
@NSManaged public var inReplyToAccountID: String?
@NSManaged public var inReplyToID: String?
@NSManaged private var mentionsData: Data?
@NSManaged public var muted: Bool
@NSManaged private var pinnedInternal: Bool
@NSManaged public var reblogged: Bool
@NSManaged public var reblogsCount: Int
@NSManaged public var referenceCount: Int
@NSManaged public var sensitive: Bool
@NSManaged public var spoilerText: String
@NSManaged public var uri: String // todo: are both uri and url necessary?
@NSManaged public var url: URL?
@NSManaged private var visibilityString: String
@NSManaged public var account: AccountMO
@NSManaged public var reblog: StatusMO?
@LazilyDecoding(arrayFrom: \StatusMO.attachmentsData)
public var attachments: [Attachment]
@LazilyDecoding(arrayFrom: \StatusMO.emojisData)
public var emojis: [Emoji]
@LazilyDecoding(arrayFrom: \StatusMO.hashtagsData)
public var hashtags: [Hashtag]
@LazilyDecoding(arrayFrom: \StatusMO.mentionsData)
public var mentions: [Mention]
public var pinned: Bool? { pinnedInternal }
public var bookmarked: Bool? { bookmarkedInternal }
public var visibility: Pachyderm.Status.Visibility {
get {
Pachyderm.Status.Visibility(rawValue: visibilityString) ?? .public
}
set {
visibilityString = newValue.rawValue
}
}
func incrementReferenceCount() {
referenceCount += 1
}
func decrementReferenceCount() {
referenceCount -= 1
if referenceCount <= 0 {
managedObjectContext!.delete(self)
}
}
public override func prepareForDeletion() {
super.prepareForDeletion()
reblog?.decrementReferenceCount()
account.decrementReferenceCount()
}
}
extension StatusMO {
convenience init(apiStatus status: Pachyderm.Status, container: MastodonCachePersistentStore, context: NSManagedObjectContext) {
self.init(context: context)
self.updateFrom(apiStatus: status, container: container)
reblog?.incrementReferenceCount()
account.incrementReferenceCount()
}
func updateFrom(apiStatus status: Pachyderm.Status, container: MastodonCachePersistentStore) {
guard let context = managedObjectContext else {
// we have been deleted, don't bother updating
return
}
self.applicationName = status.application?.name
self.attachments = status.attachments
self.bookmarkedInternal = status.bookmarked ?? false
self.content = status.content
self.createdAt = status.createdAt
self.emojis = status.emojis
self.favourited = status.favourited ?? false
self.favouritesCount = status.favouritesCount
self.hashtags = status.hashtags
self.inReplyToAccountID = status.inReplyToAccountID
self.inReplyToID = status.inReplyToID
self.id = status.id
self.mentions = status.mentions
self.muted = status.muted ?? false
self.pinnedInternal = status.pinned ?? false
self.reblogged = status.reblogged ?? false
self.reblogsCount = status.reblogsCount
self.sensitive = status.sensitive
self.spoilerText = status.spoilerText
self.uri = status.uri
self.url = status.url
self.visibility = status.visibility
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 {
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
}
}
}

View File

@ -1,65 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="16119" systemVersion="19D76" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="AccountMO" syncable="YES">
<attribute name="acct" attributeType="String"/>
<attribute name="avatar" attributeType="URI"/>
<attribute name="botCD" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="displayName" attributeType="String"/>
<attribute name="emojisData" attributeType="Binary"/>
<attribute name="fieldsData" optional="YES" attributeType="Binary"/>
<attribute name="followersCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="followingCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="header" attributeType="URI"/>
<attribute name="id" attributeType="String"/>
<attribute name="locked" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="movedCD" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="note" attributeType="String"/>
<attribute name="referenceCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="url" attributeType="URI"/>
<attribute name="username" attributeType="String"/>
<relationship name="movedTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="Status" representedClassName="StatusMO" syncable="YES">
<attribute name="applicationName" optional="YES" attributeType="String"/>
<attribute name="attachmentsData" attributeType="Binary"/>
<attribute name="bookmarkedInternal" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="content" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="emojisData" attributeType="Binary" customClassName="[Data]"/>
<attribute name="favourited" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="favouritesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="hashtagsData" attributeType="Binary"/>
<attribute name="id" attributeType="String"/>
<attribute name="inReplyToAccountID" optional="YES" attributeType="String"/>
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
<attribute name="mentionsData" attributeType="Binary"/>
<attribute name="muted" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="pinnedInternal" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="reblogged" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="reblogsCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="referenceCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sensitive" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="spoilerText" attributeType="String"/>
<attribute name="uri" attributeType="String"/>
<attribute name="url" optional="YES" attributeType="URI"/>
<attribute name="visibilityString" attributeType="String"/>
<relationship name="account" maxCount="1" deletionRule="Nullify" destinationEntity="Account"/>
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<elements>
<element name="Account" positionX="169.21875" positionY="78.9609375" width="128" height="328"/>
<element name="Status" positionX="-63" positionY="-18" width="128" height="418"/>
</elements>
</model>

View File

@ -0,0 +1,92 @@
//
// 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: [DraftAttachment]) -> 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?
private(set) var attachments: [DraftAttachment]
private(set) var inReplyToID: String?
private(set) var lastModified: Date
init(accountID: String, text: String, contentWarning: String?, inReplyToID: String?, attachments: [DraftAttachment], 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: [DraftAttachment]) {
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
}
}
struct DraftAttachment: Codable {
let attachment: CompositionAttachment
let description: String
}
}

View File

@ -9,29 +9,23 @@
import Foundation import Foundation
import Pachyderm import Pachyderm
extension AccountMO { extension Account {
var displayOrUserName: String { var realDisplayName: String {
if displayName.isEmpty { if displayName.isEmpty {
return username return username
} else if Preferences.shared.hideCustomEmojiInUsernames {
return stripCustomEmoji(from: displayName)
} else { } else {
return displayName return displayName
} }
} }
var displayNameWithoutCustomEmoji: String {
if displayName.isEmpty {
return username
} else {
return stripCustomEmoji(from: displayName)
}
}
private static let customEmojiRegex = try! NSRegularExpression(pattern: ":[a-zA-Z0-9_]+:", options: []) private static let customEmojiRegex = try! NSRegularExpression(pattern: ":[a-zA-Z0-9_]+:", options: [])
private func stripCustomEmoji(from string: String) -> String { private func stripCustomEmoji(from string: String) -> String {
let range = NSRange(location: 0, length: string.utf16.count) let range = NSRange(location: 0, length: string.utf16.count)
return AccountMO.customEmojiRegex.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "") return Account.customEmojiRegex.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
} }
} }

View File

@ -9,7 +9,32 @@
import Foundation import Foundation
extension Date { extension Date {
// var timeAgo: String {
// let calendar = NSCalendar.current
// let unitFlags = Set<Calendar.Component>([.second, .minute, .hour, .day, .weekOfYear, .month, .year])
//
// let components = calendar.dateComponents(unitFlags, from: self, to: Date())
//
// if components.year! >= 1 {
// return "\(components.year!)y"
// } else if components.month! >= 1 {
// return "\(components.month!)mo"
// } else if components.weekOfYear! >= 1 {
// return "\(components.weekOfYear!)w"
// } else if components.day! >= 1 {
// return "\(components.day!)d"
// } else if components.hour! >= 1 {
// return "\(components.hour!)h"
// } else if components.minute! >= 1 {
// return "\(components.minute!)m"
// } else if components.second! >= 3 {
// return "\(components.second!)s"
// } else {
// return "Now"
// }
// }
func timeAgo() -> (Int, Calendar.Component) { func timeAgo() -> (Int, Calendar.Component) {
let calendar = NSCalendar.current let calendar = NSCalendar.current
let unitFlags = Set<Calendar.Component>([.second, .minute, .hour, .day, .weekOfYear, .month, .year]) let unitFlags = Set<Calendar.Component>([.second, .minute, .hour, .day, .weekOfYear, .month, .year])

View File

@ -1,29 +0,0 @@
//
// NSTextAttachment+Emoji.swift
// Tusker
//
// Created by Shadowfacts on 3/1/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
extension NSTextAttachment {
// Based on https://github.com/ReticentJohn/Amaroq/blob/7c5b7088eb9fd1611dcb0f47d43bf8df093e142c/DireFloof/InlineImageHelpers.m
convenience init(emojiImage image: UIImage, in font: UIFont, with textColor: UIColor = .label) {
let adjustedCapHeight = font.capHeight - 1
var imageSizeMatchingFontSize = CGSize(width: image.size.width * (adjustedCapHeight / image.size.height), height: adjustedCapHeight)
let defaultScale: CGFloat = 1.4
imageSizeMatchingFontSize = CGSize(width: imageSizeMatchingFontSize.width * defaultScale, height: imageSizeMatchingFontSize.height * defaultScale)
UIGraphicsBeginImageContextWithOptions(imageSizeMatchingFontSize, false, 0.0)
textColor.set()
image.draw(in: CGRect(origin: .zero, size: imageSizeMatchingFontSize))
let attachmentImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
self.init()
self.image = attachmentImage
}
}

View File

@ -1,33 +0,0 @@
//
// PKDrawing+Render.swift
// Tusker
//
// Created by Shadowfacts on 5/9/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import PencilKit
extension PKDrawing {
func imageInLightMode(from rect: CGRect, scale: CGFloat = UIScreen.main.scale) -> UIImage {
let lightTraitCollection = UITraitCollection(userInterfaceStyle: .light)
var drawingImage: UIImage!
lightTraitCollection.performAsCurrent {
drawingImage = self.image(from: rect, scale: scale)
}
let imageRect = CGRect(origin: .zero, size: rect.size)
let format = UIGraphicsImageRendererFormat()
format.opaque = false
format.scale = scale
let renderer = UIGraphicsImageRenderer(size: rect.size, format: format)
return renderer.image { (context) in
UIColor.white.setFill()
context.fill(imageRect)
drawingImage.draw(in: imageRect)
}
}
}

View File

@ -1,30 +0,0 @@
//
// 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)
}
}

View File

@ -1,21 +0,0 @@
//
// 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
}
}
}

View File

@ -1,67 +0,0 @@
//
// 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))
}
}

View File

@ -10,17 +10,23 @@ import UIKit
extension UIViewController: UIViewControllerTransitioningDelegate { extension UIViewController: UIViewControllerTransitioningDelegate {
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if let presented = presented as? LargeImageAnimatableViewController, if let presented = presented as? LargeImageViewController,
presented.animationImage != nil { presented.sourceInfo?.image != nil {
return LargeImageExpandAnimationController() return LargeImageExpandAnimationController()
} else if let presented = presented as? GalleryViewController,
presented.sourcesInfo[presented.startIndex]?.image != nil {
return GalleryExpandAnimationController()
} }
return nil return nil
} }
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if let dismissed = dismissed as? LargeImageAnimatableViewController, if let dismissed = dismissed as? LargeImageViewController,
dismissed.animationImage != nil { dismissed.imageForDismissalAnimation() != nil {
return LargeImageShrinkAnimationController(interactionController: dismissed.dismissInteractionController) return LargeImageShrinkAnimationController(interactionController: dismissed.dismissInteractionController)
} else if let dismissed = dismissed as? GalleryViewController,
dismissed.imageForDismissalAnimation() != nil {
return GalleryShrinkAnimationController(interactionController: dismissed.dismissInteractionController)
} }
return nil return nil
} }
@ -30,6 +36,10 @@ extension UIViewController: UIViewControllerTransitioningDelegate {
let interactionController = animator.interactionController, let interactionController = animator.interactionController,
interactionController.inProgress { interactionController.inProgress {
return interactionController return interactionController
} else if let animator = animator as? GalleryShrinkAnimationController,
let interactionController = animator.interactionController,
interactionController.inProgress {
return interactionController
} }
return nil return nil
} }

View File

@ -1,22 +0,0 @@
//
// 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
}
}
}

View File

@ -37,17 +37,4 @@ extension Status.Visibility {
} }
} }
var unfilledImageName: String {
switch self {
case .public:
return "globe"
case .unlisted:
return "lock.open"
case .private:
return "lock"
case .direct:
return "envelope"
}
}
} }

View File

@ -15,7 +15,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string> <string>1.0</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>
@ -30,9 +30,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>1</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.social-networking</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
@ -100,16 +98,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeConformsTo</key>
<array/>
<key>UTTypeIdentifier</key>
<string>space.vaccor.Tusker.composition-attachment</string>
<key>UTTypeTagSpecification</key>
<dict/>
</dict>
</array>
</dict> </dict>
</plist> </plist>

View File

@ -1,64 +0,0 @@
//
// LazyDecoding.swift
// Tusker
//
// Created by Shadowfacts on 4/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
private let decoder = PropertyListDecoder()
private let encoder = PropertyListEncoder()
// todo: invalidate cache on underlying data change using KVO?
@propertyWrapper
public struct LazilyDecoding<Enclosing, Value: Codable> {
private let keyPath: ReferenceWritableKeyPath<Enclosing, Data?>
private let fallback: Value
private var value: Value?
init(from keyPath: ReferenceWritableKeyPath<Enclosing, Data?>, fallback: Value) {
self.keyPath = keyPath
self.fallback = fallback
}
public var wrappedValue: Value {
get { fatalError("called LazilyDecoding wrappedValue getter") }
set { fatalError("called LazilyDecoding wrappedValue setter") }
}
public static subscript(_enclosingInstance instance: Enclosing, wrapped wrappedKeyPath: ReferenceWritableKeyPath<Enclosing, Value>, storage storageKeyPath: ReferenceWritableKeyPath<Enclosing, Self>) -> Value {
get {
var wrapper = instance[keyPath: storageKeyPath]
if let value = wrapper.value {
return value
} else {
guard let data = instance[keyPath: wrapper.keyPath] else { return wrapper.fallback }
do {
let value = try decoder.decode(Value.self, from: data)
wrapper.value = value
instance[keyPath: storageKeyPath] = wrapper
return value
} catch {
return wrapper.fallback
}
}
}
set {
var wrapper = instance[keyPath: storageKeyPath]
wrapper.value = newValue
instance[keyPath: storageKeyPath] = wrapper
let newData = try? encoder.encode(newValue)
instance[keyPath: wrapper.keyPath] = newData
}
}
}
extension LazilyDecoding {
init(arrayFrom keyPath: ReferenceWritableKeyPath<Enclosing, Data?>) {
self.init(from: keyPath, fallback: [] as! Value)
}
}

View File

@ -18,7 +18,6 @@ class LocalData: ObservableObject {
private init() { private init() {
if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING") { if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING") {
defaults = UserDefaults(suiteName: "\(Bundle.main.bundleIdentifier!).uitesting")! defaults = UserDefaults(suiteName: "\(Bundle.main.bundleIdentifier!).uitesting")!
defaults.removePersistentDomain(forName: "\(Bundle.main.bundleIdentifier!).uitesting")
if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING_LOGIN") { if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING_LOGIN") {
accounts = [ accounts = [
UserAccountInfo( UserAccountInfo(
@ -31,20 +30,7 @@ class LocalData: ObservableObject {
] ]
} }
} else { } else {
defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")! defaults = UserDefaults()
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)
} }
} }
@ -58,10 +44,11 @@ class LocalData: ObservableObject {
let url = URL(string: instanceURL), let url = URL(string: instanceURL),
let clientId = info["clientID"], let clientId = info["clientID"],
let secret = info["clientSecret"], let secret = info["clientSecret"],
let username = info["username"],
let accessToken = info["accessToken"] else { let accessToken = info["accessToken"] else {
return nil return nil
} }
return UserAccountInfo(id: id, instanceURL: url, clientID: clientId, clientSecret: secret, username: info["username"], accessToken: accessToken) return UserAccountInfo(id: id, instanceURL: url, clientID: clientId, clientSecret: secret, username: username, accessToken: accessToken)
} }
} else { } else {
return [] return []
@ -69,18 +56,15 @@ class LocalData: ObservableObject {
} }
set { set {
objectWillChange.send() objectWillChange.send()
let array = newValue.map { (info) -> [String: String] in let array = newValue.map { (info) in
var res = [ return [
"id": info.id, "id": info.id,
"instanceURL": info.instanceURL.absoluteString, "instanceURL": info.instanceURL.absoluteString,
"clientID": info.clientID, "clientID": info.clientID,
"clientSecret": info.clientSecret, "clientSecret": info.clientSecret,
"username": info.username,
"accessToken": info.accessToken "accessToken": info.accessToken
] ]
if let username = info.username {
res["username"] = username
}
return res
} }
defaults.set(array, forKey: accountsKey) defaults.set(array, forKey: accountsKey)
} }
@ -101,7 +85,7 @@ class LocalData: ObservableObject {
return !accounts.isEmpty return !accounts.isEmpty
} }
func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String?, accessToken: String) -> UserAccountInfo { func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String, accessToken: String) -> UserAccountInfo {
var accounts = self.accounts var accounts = self.accounts
if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) { if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) {
accounts.remove(at: index) accounts.remove(at: index)
@ -113,13 +97,6 @@ class LocalData: ObservableObject {
return info return info
} }
func setUsername(for info: UserAccountInfo, username: String) {
var info = info
info.username = username
removeAccount(info)
accounts.append(info)
}
func removeAccount(_ info: UserAccountInfo) { func removeAccount(_ info: UserAccountInfo) {
accounts.removeAll(where: { $0.id == info.id }) accounts.removeAll(where: { $0.id == info.id })
} }
@ -151,7 +128,7 @@ extension LocalData {
let instanceURL: URL let instanceURL: URL
let clientID: String let clientID: String
let clientSecret: String let clientSecret: String
fileprivate(set) var username: String! let username: String
let accessToken: String let accessToken: String
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {

207
Tusker/MastodonCache.swift Normal file
View File

@ -0,0 +1,207 @@
//
// StatusCache.swift
// Tusker
//
// Created by Shadowfacts on 9/17/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
import Combine
import Pachyderm
class MastodonCache {
private var statuses = CachedDictionary<Status>(name: "Statuses")
private var accounts = CachedDictionary<Account>(name: "Accounts")
private var relationships = CachedDictionary<Relationship>(name: "Relationships")
private var notifications = CachedDictionary<Pachyderm.Notification>(name: "Notifications")
let statusSubject = PassthroughSubject<Status, Never>()
let accountSubject = PassthroughSubject<Account, Never>()
weak var mastodonController: MastodonController?
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
}
// MARK: - Statuses
func status(for id: String) -> Status? {
return statuses[id]
}
func set(status: Status, for id: String) {
statuses[id] = status
add(account: status.account)
if let reblog = status.reblog {
add(status: reblog)
add(account: reblog.account)
}
statusSubject.send(status)
}
func status(for id: String, completion: @escaping (Status?) -> Void) {
guard let mastodonController = mastodonController else {
fatalError("The MastodonController for this cache has been deinitialized, so this cache should no longer exist. Are you storing a strong reference to it?")
}
let request = Client.getStatus(id: id)
mastodonController.run(request) { response in
guard case let .success(status, _) = response else {
completion(nil)
return
}
self.set(status: status, for: id)
completion(status)
}
}
func status(for id: String) -> Promise<Status> {
guard let mastodonController = mastodonController else {
fatalError("The MastodonController for this cache has been deinitialized, so this cache should no longer exist. Are you storing a strong reference to it?")
}
let request = Client.getStatus(id: id)
return mastodonController.run(request).then { (status, _) in
status
}.then(self.add(status:))
}
func add(status: Status) {
set(status: status, for: status.id)
}
func addAll(statuses: [Status]) {
statuses.forEach(add)
}
// MARK: - Accounts
func account(for id: String) -> Account? {
return accounts[id]
}
func set(account: Account, for id: String) {
accounts[id] = account
accountSubject.send(account)
}
func account(for id: String, completion: @escaping (Account?) -> Void) {
guard let mastodonController = mastodonController else {
fatalError("The MastodonController for this cache has been deinitialized, so this cache should no longer exist. Are you storing a strong reference to it?")
}
let request = Client.getAccount(id: id)
mastodonController.run(request) { response in
guard case let .success(account, _) = response else {
completion(nil)
return
}
self.set(account: account, for: account.id)
completion(account)
}
}
func account(for id: String) -> Promise<Account> {
guard let mastodonController = mastodonController else {
fatalError("The MastodonController for this cache has been deinitialized, so this cache should no longer exist. Are you storing a strong reference to it?")
}
let request = Client.getAccount(id: id)
return mastodonController.run(request).then { (account, _) in
account
}.then(self.add(account:))
}
func add(account: Account) {
set(account: account, for: account.id)
}
func addAll(accounts: [Account]) {
accounts.forEach(add)
}
// MARK: - Relationships
func relationship(for id: String) -> Relationship? {
return relationships[id]
}
func set(relationship: Relationship, id: String) {
relationships[id] = relationship
}
func relationship(for id: String, completion: @escaping (Relationship?) -> Void) {
guard let mastodonController = mastodonController else {
fatalError("The MastodonController for this cache has been deinitialized, so this cache should no longer exist. Are you storing a strong reference to it?")
}
let request = Client.getRelationships(accounts: [id])
mastodonController.run(request) { response in
guard case let .success(relationships, _) = response,
let relationship = relationships.first else {
completion(nil)
return
}
self.set(relationship: relationship, id: relationship.id)
completion(relationship)
}
}
func relationship(for id: String) -> Promise<Relationship> {
guard let mastodonController = mastodonController else {
fatalError("The MastodonController for this cache has been deinitialized, so this cache should no longer exist. Are you storing a strong reference to it?")
}
let request = Client.getRelationships(accounts: [id])
return mastodonController.run(request).then { (relationships, _) in
relationships.first!
}.then(self.add(relationship:))
}
func add(relationship: Relationship) {
set(relationship: relationship, id: relationship.id)
}
func addAll(relationships: [Relationship]) {
relationships.forEach(add)
}
// MARK: - Notifications
func notification(for id: String) -> Pachyderm.Notification? {
return notifications[id]
}
func set(notification: Pachyderm.Notification, id: String) {
notifications[id] = notification
}
func add(notification: Pachyderm.Notification) {
set(notification: notification, id: notification.id)
}
func addAll(notifications: [Pachyderm.Notification]) {
notifications.forEach(add)
}
}
class CachedDictionary<Value> {
private let name: String
private var dict = [String: Value]()
private let queue: DispatchQueue
init(name: String) {
self.name = name
self.queue = DispatchQueue(label: "CachedDictionary (\(name)) Coordinator", attributes: .concurrent)
}
subscript(key: String) -> Value? {
get {
var result: Value? = nil
queue.sync {
result = dict[key]
}
return result
}
set(value) {
queue.async(flags: .barrier) {
self.dict[key] = value
}
}
}
}

View File

@ -1,116 +0,0 @@
//
// CompositionAttachment.swift
// Tusker
//
// Created by Shadowfacts on 3/14/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
import UIKit
import MobileCoreServices
final class CompositionAttachment: NSObject, Codable, ObservableObject {
static let typeIdentifier = "space.vaccor.Tusker.composition-attachment"
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.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
private let dataType = kUTTypeData as String
extension CompositionAttachment: NSItemProviderWriting {
static var writableTypeIdentifiersForItemProvider: [String] {
[typeIdentifier]
}
func loadData(withTypeIdentifier typeIdentifier: String, forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void) -> Progress? {
if typeIdentifier == CompositionAttachment.typeIdentifier {
do {
completionHandler(try PropertyListEncoder().encode(self), nil)
} catch {
completionHandler(nil, error)
}
}
completionHandler(nil, ItemProviderError.incompatibleTypeIdentifier)
return nil
}
enum ItemProviderError: Error {
case incompatibleTypeIdentifier
var localizedDescription: String {
switch self {
case .incompatibleTypeIdentifier:
return "Cannot provide data for given type"
}
}
}
}
extension CompositionAttachment: NSItemProviderReading {
static var readableTypeIdentifiersForItemProvider: [String] {
// todo: is there a better way of handling movies than manually adding all possible UTI types?
// just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension
// without the file extension, getting the thumbnail and exporting the video for attachment upload fails
[typeIdentifier] + UIImage.readableTypeIdentifiersForItemProvider + [mp4Type, quickTimeType] + NSURL.readableTypeIdentifiersForItemProvider
}
static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> Self {
if typeIdentifier == CompositionAttachment.typeIdentifier {
return try PropertyListDecoder().decode(Self.self, from: data)
} else if UIImage.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let image = try? UIImage.object(withItemProviderData: data, typeIdentifier: typeIdentifier) {
return CompositionAttachment(data: .image(image)) as! Self
} else if typeIdentifier == mp4Type || typeIdentifier == quickTimeType {
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let temporaryFileName = ProcessInfo().globallyUniqueString
let fileExt = UTTypeCopyPreferredTagWithClass(typeIdentifier as CFString, kUTTagClassFilenameExtension)!
let temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(temporaryFileName).appendingPathExtension(fileExt.takeUnretainedValue() as String)
try data.write(to: temporaryFileURL)
return CompositionAttachment(data: .video(temporaryFileURL)) as! Self
} else if NSURL.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let url = try? NSURL.object(withItemProviderData: data, typeIdentifier: typeIdentifier) as URL {
return CompositionAttachment(data: .video(url)) as! Self
} else {
throw ItemProviderError.incompatibleTypeIdentifier
}
}
}

View File

@ -1,164 +0,0 @@
//
// 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]()
var visibility = Preferences.shared.defaultPostVisibility
var contentWarning = ""
if let inReplyToID = inReplyToID,
let inReplyTo = persistentContainer.status(for: inReplyToID) {
acctsToMention.append(inReplyTo.account.acct)
acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct))
visibility = inReplyTo.visibility
if !inReplyTo.spoilerText.isEmpty {
switch Preferences.shared.contentWarningCopyMode {
case .doNotCopy:
break
case .asIs:
contentWarning = inReplyTo.spoilerText
case .prependRe:
if inReplyTo.spoilerText.lowercased().starts(with: "re:") {
contentWarning = inReplyTo.spoilerText
} else {
contentWarning = "re: \(inReplyTo.spoilerText)"
}
}
}
}
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
draft.visibility = visibility
draft.contentWarning = contentWarning
draft.contentWarningEnabled = !contentWarning.isEmpty
return draft
}
}

View File

@ -1,50 +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: .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 }
}
}

View File

@ -38,29 +38,19 @@ class Preferences: Codable, ObservableObject {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme) 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.avatarStyle = try container.decode(AvatarStyle.self, forKey: .avatarStyle)
self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames) self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames)
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon)
self.defaultPostVisibility = try container.decode(Status.Visibility.self, forKey: .defaultPostVisibility) self.defaultPostVisibility = try container.decode(Status.Visibility.self, forKey: .defaultPostVisibility)
self.automaticallySaveDrafts = try container.decode(Bool.self, forKey: .automaticallySaveDrafts) self.automaticallySaveDrafts = try container.decode(Bool.self, forKey: .automaticallySaveDrafts)
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions) self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode) self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger) self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
self.blurAllMedia = try container.decode(Bool.self, forKey: .blurAllMedia) self.blurAllMedia = try container.decode(Bool.self, forKey: .blurAllMedia)
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps) self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari) self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode) 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.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType) self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
@ -73,25 +63,19 @@ class Preferences: Codable, ObservableObject {
var container = encoder.container(keyedBy: CodingKeys.self) var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(theme, forKey: .theme) try container.encode(theme, forKey: .theme)
try container.encode(showRepliesInProfiles, forKey: .showRepliesInProfiles)
try container.encode(avatarStyle, forKey: .avatarStyle) try container.encode(avatarStyle, forKey: .avatarStyle)
try container.encode(hideCustomEmojiInUsernames, forKey: .hideCustomEmojiInUsernames) try container.encode(hideCustomEmojiInUsernames, forKey: .hideCustomEmojiInUsernames)
try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon)
try container.encode(alwaysShowStatusVisibilityIcon, forKey: .alwaysShowStatusVisibilityIcon)
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility) try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
try container.encode(automaticallySaveDrafts, forKey: .automaticallySaveDrafts) try container.encode(automaticallySaveDrafts, forKey: .automaticallySaveDrafts)
try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions) try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions)
try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode) try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode)
try container.encode(mentionReblogger, forKey: .mentionReblogger) try container.encode(mentionReblogger, forKey: .mentionReblogger)
try container.encode(blurAllMedia, forKey: .blurAllMedia) try container.encode(blurAllMedia, forKey: .blurAllMedia)
try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
try container.encode(openLinksInApps, forKey: .openLinksInApps) try container.encode(openLinksInApps, forKey: .openLinksInApps)
try container.encode(useInAppSafari, forKey: .useInAppSafari) try container.encode(useInAppSafari, forKey: .useInAppSafari)
try container.encode(inAppSafariAutomaticReaderMode, forKey: .inAppSafariAutomaticReaderMode) 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(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts)
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType) try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
@ -100,60 +84,46 @@ class Preferences: Codable, ObservableObject {
try container.encode(statusContentType, forKey: .statusContentType) try container.encode(statusContentType, forKey: .statusContentType)
} }
// MARK: Appearance // MARK: - Appearance
@Published var theme = UIUserInterfaceStyle.unspecified @Published var theme = UIUserInterfaceStyle.unspecified
@Published var showRepliesInProfiles = false
@Published var avatarStyle = AvatarStyle.roundRect @Published var avatarStyle = AvatarStyle.roundRect
@Published var hideCustomEmojiInUsernames = false @Published var hideCustomEmojiInUsernames = false
@Published var showIsStatusReplyIcon = false
@Published var alwaysShowStatusVisibilityIcon = false
// MARK: Composing // MARK: - Behavior
@Published var defaultPostVisibility = Status.Visibility.public @Published var defaultPostVisibility = Status.Visibility.public
@Published var automaticallySaveDrafts = true @Published var automaticallySaveDrafts = true
@Published var requireAttachmentDescriptions = false @Published var requireAttachmentDescriptions = false
@Published var contentWarningCopyMode = ContentWarningCopyMode.asIs @Published var contentWarningCopyMode = ContentWarningCopyMode.asIs
@Published var mentionReblogger = false @Published var mentionReblogger = false
// MARK: Media
@Published var blurAllMedia = false @Published var blurAllMedia = false
@Published var automaticallyPlayGifs = true
// MARK: Behavior
@Published var openLinksInApps = true @Published var openLinksInApps = true
@Published var useInAppSafari = true @Published var useInAppSafari = true
@Published var inAppSafariAutomaticReaderMode = false @Published var inAppSafariAutomaticReaderMode = false
@Published var expandAllContentWarnings = false
@Published var collapseLongPosts = true
// MARK: Digital Wellness // MARK: - Digital Wellness
@Published var showFavoriteAndReblogCounts = true @Published var showFavoriteAndReblogCounts = true
@Published var defaultNotificationsMode = NotificationsMode.allNotifications @Published var defaultNotificationsMode = NotificationsMode.allNotifications
// MARK: Advanced // MARK: - Advanced
@Published var silentActions: [String: Permission] = [:] @Published var silentActions: [String: Permission] = [:]
@Published var statusContentType: StatusContentType = .plain @Published var statusContentType: StatusContentType = .plain
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case theme case theme
case showRepliesInProfiles
case avatarStyle case avatarStyle
case hideCustomEmojiInUsernames case hideCustomEmojiInUsernames
case showIsStatusReplyIcon
case alwaysShowStatusVisibilityIcon
case defaultPostVisibility case defaultPostVisibility
case automaticallySaveDrafts case automaticallySaveDrafts
case requireAttachmentDescriptions case requireAttachmentDescriptions
case contentWarningCopyMode case contentWarningCopyMode
case mentionReblogger case mentionReblogger
case blurAllMedia case blurAllMedia
case automaticallyPlayGifs
case openLinksInApps case openLinksInApps
case useInAppSafari case useInAppSafari
case inAppSafariAutomaticReaderMode case inAppSafariAutomaticReaderMode
case expandAllContentWarnings
case collapseLongPosts
case showFavoriteAndReblogCounts case showFavoriteAndReblogCounts
case defaultNotificationsType case defaultNotificationsType

View File

@ -8,8 +8,6 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import CrashReporter
import MessageUI
class SceneDelegate: UIResponder, UIWindowSceneDelegate { class SceneDelegate: UIResponder, UIWindowSceneDelegate {
@ -23,13 +21,17 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
window = UIWindow(windowScene: windowScene) window = UIWindow(windowScene: windowScene)
if let report = AppDelegate.pendingCrashReport { if LocalData.shared.onboardingComplete {
AppDelegate.pendingCrashReport = nil if session.mastodonController == nil {
handlePendingCrashReport(report, session: session) let account = LocalData.shared.getMostRecentAccount()!
session.mastodonController = MastodonController.getForAccount(account)
}
showAppUI()
} else { } else {
showAppOrOnboardingUI(session: session) showOnboardingUI()
} }
window!.makeKeyAndVisible() window!.makeKeyAndVisible()
NotificationCenter.default.addObserver(self, selector: #selector(themePrefChanged), name: .themePreferenceChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(themePrefChanged), name: .themePreferenceChanged, object: nil)
@ -80,9 +82,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// This occurs shortly after the scene enters the background, or when its session is discarded. // This occurs shortly after the scene enters the background, or when its session is discarded.
// Release any resources associated with this scene that can be re-created the next time the scene connects. // Release any resources associated with this scene that can be re-created the next time the scene connects.
// The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead).
Preferences.save()
DraftsManager.save()
} }
func sceneDidBecomeActive(_ scene: UIScene) { func sceneDidBecomeActive(_ scene: UIScene) {
@ -93,9 +92,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func sceneWillResignActive(_ scene: UIScene) { func sceneWillResignActive(_ scene: UIScene) {
// Called when the scene will move from an active state to an inactive state. // Called when the scene will move from an active state to an inactive state.
// This may occur due to temporary interruptions (ex. an incoming phone call). // This may occur due to temporary interruptions (ex. an incoming phone call).
Preferences.save()
DraftsManager.save()
} }
func sceneWillEnterForeground(_ scene: UIScene) { func sceneWillEnterForeground(_ scene: UIScene) {
@ -108,33 +104,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// Use this method to save data, release shared resources, and store enough scene-specific state information // Use this method to save data, release shared resources, and store enough scene-specific state information
// to restore the scene back to its current state. // to restore the scene back to its current state.
try! scene.session.mastodonController?.persistentContainer.viewContext.save() Preferences.save()
} DraftsManager.save()
private func handlePendingCrashReport(_ report: PLCrashReport, session: UISceneSession) {
#if !DEBUG
guard MFMailComposeViewController.canSendMail() else {
print("Cannot send email")
showAppOrOnboardingUI(session: session)
return
}
window!.rootViewController = CrashReporterViewController.create(report: report)
#endif
}
func showAppOrOnboardingUI(session: UISceneSession? = nil) {
let session = session ?? window!.windowScene!.session
if LocalData.shared.onboardingComplete {
if session.mastodonController == nil {
let account = LocalData.shared.getMostRecentAccount()!
session.mastodonController = MastodonController.getForAccount(account)
}
showAppUI()
} else {
showOnboardingUI()
}
} }
func activateAccount(_ account: LocalData.UserAccountInfo) { func activateAccount(_ account: LocalData.UserAccountInfo) {
@ -157,17 +128,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
mastodonController.getOwnAccount() mastodonController.getOwnAccount()
mastodonController.getOwnInstance() mastodonController.getOwnInstance()
let rootController: UIViewController let tabBarController = MainTabBarViewController(mastodonController: mastodonController)
#if SDK_IOS_14 window!.rootViewController = tabBarController
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() { func showOnboardingUI() {
@ -187,11 +149,3 @@ extension SceneDelegate: OnboardingViewControllerDelegate {
activateAccount(account) activateAccount(account)
} }
} }
extension SceneDelegate: MFMailComposeViewControllerDelegate {
func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
controller.dismiss(animated: true) {
self.showAppOrOnboardingUI()
}
}
}

View File

@ -32,8 +32,7 @@ class AccountListTableViewController: EnhancedTableViewController {
tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell) tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell)
tableView.rowHeight = UITableView.automaticDimension tableView.rowHeight = 66
tableView.estimatedRowHeight = 66
tableView.alwaysBounceVertical = true tableView.alwaysBounceVertical = true

View File

@ -1,215 +0,0 @@
//
// AssetCollectionViewController.swift
// Tusker
//
// Created by Shadowfacts on 1/1/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Photos
private let reuseIdentifier = "assetCell"
private let cameraReuseIdentifier = "showCameraCell"
protocol AssetCollectionViewControllerDelegate: class {
func shouldSelectAsset(_ asset: PHAsset) -> Bool
func didSelectAssets(_ assets: [PHAsset])
func captureFromCamera()
}
class AssetCollectionViewController: UICollectionViewController {
weak var delegate: AssetCollectionViewControllerDelegate?
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var flowLayout: UICollectionViewFlowLayout {
return collectionViewLayout as! UICollectionViewFlowLayout
}
private var availableWidth: CGFloat!
private var thumbnailSize: CGSize!
private let imageManager = PHCachingImageManager()
private var fetchResult: PHFetchResult<PHAsset>!
var selectedAssets: [PHAsset] {
return collectionView.indexPathsForSelectedItems?.compactMap { (indexPath) in
guard case let .asset(asset) = dataSource.itemIdentifier(for: indexPath) else { return nil }
return asset
} ?? []
}
init() {
super.init(collectionViewLayout: UICollectionViewFlowLayout())
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
// use the safe area layout guide instead of letting it automatically use the safe area insets
// because otherwise, when presented in a popover with the arrow on the left or right side,
// the collection view content will be cut off by the width of the arrow because the popover
// doesn't respect safe area insets
collectionView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
view.safeAreaLayoutGuide.leadingAnchor.constraint(equalTo: collectionView.leadingAnchor),
view.safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: collectionView.trailingAnchor),
// top ignores safe area because when presented in the sheet container, it simplifies the top content offset
view.topAnchor.constraint(equalTo: collectionView.topAnchor),
// bottom ignores safe area because we want cells to underflow bottom of the screen on notched iPhones
view.bottomAnchor.constraint(equalTo: collectionView.bottomAnchor),
])
view.backgroundColor = .systemBackground
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(donePressed))
collectionView.alwaysBounceVertical = true
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)
updateItemsSelectedCount()
if let singleFingerPanGesture = collectionView.gestureRecognizers?.first(where: {
$0.name == "multi-select.singleFingerPanGesture"
}),
let interactivePopGesture = navigationController?.interactivePopGestureRecognizer {
singleFingerPanGesture.require(toFail: interactivePopGesture)
}
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
let availableWidth = view.bounds.inset(by: view.safeAreaInsets).width
if self.availableWidth != availableWidth {
self.availableWidth = availableWidth
let size = (availableWidth - 8) / 3
flowLayout.itemSize = CGSize(width: size, height: size)
flowLayout.minimumInteritemSpacing = 4
flowLayout.minimumLineSpacing = 4
}
}
open func fetchAssets(with options: PHFetchOptions) -> PHFetchResult<PHAsset> {
return PHAsset.fetchAssets(with: options)
}
func updateItemsSelectedCount() {
let selected = collectionView.indexPathsForSelectedItems?.count ?? 0
navigationItem.title = "\(selected) selected"
}
// MARK: UICollectionViewDelegate
override func collectionView(_ collectionView: UICollectionView, shouldBeginMultipleSelectionInteractionAt indexPath: IndexPath) -> Bool {
return true
}
override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
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) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .showCamera:
collectionView.deselectItem(at: indexPath, animated: false)
delegate?.captureFromCamera()
case .asset(_):
updateItemsSelectedCount()
}
}
override func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
updateItemsSelectedCount()
}
override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
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?,
let cell = collectionView.cellForItem(at: indexPath) as? AssetCollectionViewCell {
let parameters = UIPreviewParameters()
parameters.backgroundColor = .black
return UITargetedPreview(view: cell.imageView, parameters: parameters)
} else {
return nil
}
}
// MARK: - Interaction
@objc func donePressed() {
delegate?.didSelectAssets(selectedAssets)
dismiss(animated: true)
}
}
extension AssetCollectionViewController {
enum Section: Hashable {
case assets
}
enum Item: Hashable {
case showCamera
case asset(PHAsset)
}
}

View File

@ -1,46 +0,0 @@
//
// AttachmentPreviewViewController.swift
// Tusker
//
// Created by Shadowfacts on 3/20/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import Gifu
class AttachmentPreviewViewController: UIViewController {
let attachment: Attachment
init(attachment: Attachment) {
self.attachment = attachment
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
if let data = ImageCache.attachments.get(attachment.url),
let image = UIImage(data: data) {
let imageView: UIImageView
if attachment.url.pathExtension == "gif" {
let gifView = GIFImageView(image: image)
gifView.animate(withGIFData: data)
imageView = gifView
} else {
imageView = UIImageView(image: image)
}
imageView.contentMode = .scaleAspectFit
imageView.backgroundColor = .black
view = imageView
preferredContentSize = image.size
} else {
view = UIActivityIndicatorView(style: .large)
}
}
}

View File

@ -1,36 +0,0 @@
//
// GalleryPlayerViewController.swift
// Tusker
//
// Created by Shadowfacts on 6/21/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import AVKit
class GalleryPlayerViewController: AVPlayerViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// starting while audio is already playing from another app often takes nearly a second,
// so do it on a background thread as to not block the UI
DispatchQueue.global(qos: .userInitiated).async {
AudioSessionHelper.enable()
AudioSessionHelper.setVideoPlayback()
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// starting often takes around half a second,
// so do it on a background thread as to not block the UI
DispatchQueue.global(qos: .userInitiated).async {
AudioSessionHelper.setDefault()
AudioSessionHelper.disable()
}
}
}

View File

@ -1,33 +0,0 @@
//
// GifvAttachmentViewController.swift
// Tusker
//
// Created by Shadowfacts on 5/12/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import AVFoundation
class GifvAttachmentViewController: UIViewController {
private let attachment: Attachment
init(attachment: Attachment) {
precondition(attachment.kind == .gifv)
self.attachment = attachment
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
let asset = AVURLAsset(url: attachment.url)
self.view = GifvAttachmentView(asset: asset, gravity: .resizeAspect)
}
}

View File

@ -8,16 +8,14 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
class PendingLargeImageViewController: UIViewController { class AttachmentViewController: UIViewController {
let attachment: Attachment
let url: URL
let cache: ImageCache
let imageDescription: String?
var largeImageVC: LargeImageViewController? var largeImageVC: LargeImageViewController?
var loadingVC: LoadingViewController? var loadingVC: LoadingViewController?
var imageRequest: ImageCache.Request? var attachmentRequest: ImageCache.Request?
private var initialControlsVisible: Bool = true private var initialControlsVisible: Bool = true
var controlsVisible: Bool { var controlsVisible: Bool {
@ -37,16 +35,10 @@ class PendingLargeImageViewController: UIViewController {
return largeImageVC return largeImageVC
} }
init(url: URL, cache: ImageCache, imageDescription: String?) { init(attachment: Attachment) {
self.url = url self.attachment = attachment
self.cache = cache
self.imageDescription = imageDescription
super.init(nibName: nil, bundle: nil) super.init(nibName: "AttachmentViewController", bundle: nil)
}
convenience init(attachment: Attachment) {
self.init(url: attachment.url, cache: .attachments, imageDescription: attachment.description)
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -59,14 +51,14 @@ class PendingLargeImageViewController: UIViewController {
overrideUserInterfaceStyle = .dark overrideUserInterfaceStyle = .dark
view.backgroundColor = .black view.backgroundColor = .black
if let data = cache.get(url) { if let data = ImageCache.attachments.get(attachment.url) {
createLargeImage(data: data) createLargeImage(data: data)
} else { } else {
loadingVC = LoadingViewController() loadingVC = LoadingViewController()
embedChild(loadingVC!) embedChild(loadingVC!)
imageRequest = cache.get(url) { [weak self] (data) in attachmentRequest = ImageCache.attachments.get(attachment.url) { [weak self] (data) in
guard let self = self else { return } guard let self = self else { return }
self.imageRequest = nil self.attachmentRequest = nil
DispatchQueue.main.async { DispatchQueue.main.async {
self.loadingVC?.removeViewAndController() self.loadingVC?.removeViewAndController()
self.createLargeImage(data: data!) self.createLargeImage(data: data!)
@ -79,16 +71,16 @@ class PendingLargeImageViewController: UIViewController {
super.didMove(toParent: parent) super.didMove(toParent: parent)
if parent == nil { if parent == nil {
imageRequest?.cancel() attachmentRequest?.cancel()
} }
} }
func createLargeImage(data: Data) { func createLargeImage(data: Data) {
guard let image = UIImage(data: data) else { return } guard let image = UIImage(data: data) else { return }
largeImageVC = LargeImageViewController(image: image, description: imageDescription, sourceInfo: nil) largeImageVC = LargeImageViewController(image: image, description: attachment.description, sourceInfo: nil)
largeImageVC!.initialControlsVisible = initialControlsVisible largeImageVC!.initialControlsVisible = initialControlsVisible
largeImageVC!.shrinkGestureEnabled = false largeImageVC!.shrinkGestureEnabled = false
if url.pathExtension == "gif" { if attachment.url.pathExtension == "gif" {
largeImageVC!.gifData = data largeImageVC!.gifData = data
} }
embedChild(largeImageVC!) embedChild(largeImageVC!)

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14810.11" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14766.13"/>
<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="AttachmentViewController" customModule="Tusker" customModuleProvider="target">
<connections>
<outlet property="view" destination="i5M-Pr-FkT" id="sfx-zR-JGt"/>
</connections>
</placeholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view clearsContextBeforeDrawing="NO" contentMode="scaleToFill" id="i5M-Pr-FkT">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<viewLayoutGuide key="safeArea" id="fnl-2z-Ty3"/>
<point key="canvasLocation" x="139" y="3"/>
</view>
</objects>
</document>

View File

@ -15,8 +15,6 @@ class BookmarksTableViewController: EnhancedTableViewController {
let mastodonController: MastodonController let mastodonController: MastodonController
private var loaded = false
var statuses: [(id: String, state: StatusState)] = [] var statuses: [(id: String, state: StatusState)] = []
var newer: RequestRange? var newer: RequestRange?
@ -43,30 +41,21 @@ class BookmarksTableViewController: EnhancedTableViewController {
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: statusCell) tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: statusCell)
tableView.prefetchDataSource = self tableView.prefetchDataSource = self
userActivity = UserActivityManager.bookmarksActivity()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if !loaded { let request = Client.getBookmarks()
loaded = true mastodonController.run(request) { (response) in
guard case let .success(statuses, pagination) = response else { fatalError() }
let request = Client.getBookmarks() self.mastodonController.cache.addAll(statuses: statuses)
mastodonController.run(request) { (response) in self.statuses.append(contentsOf: statuses.map { ($0.id, .unknown) })
guard case let .success(statuses, pagination) = response else { fatalError() } self.newer = pagination?.newer
self.mastodonController.persistentContainer.addAll(statuses: statuses) { self.older = pagination?.older
self.statuses.append(contentsOf: statuses.map { ($0.id, .unknown) })
self.newer = pagination?.newer DispatchQueue.main.async {
self.older = pagination?.older self.tableView.reloadData()
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
} }
} }
userActivity = UserActivityManager.bookmarksActivity()
} }
// MARK: - Table view data source // MARK: - Table view data source
@ -98,16 +87,15 @@ class BookmarksTableViewController: EnhancedTableViewController {
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
guard case let .success(newStatuses, pagination) = response else { fatalError() } guard case let .success(newStatuses, pagination) = response else { fatalError() }
self.older = pagination?.older self.older = pagination?.older
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) { self.mastodonController.cache.addAll(statuses: newStatuses)
let newIndexPaths = (self.statuses.count..<(self.statuses.count + newStatuses.count)).map { let newIndexPaths = (self.statuses.count..<(self.statuses.count + newStatuses.count)).map {
IndexPath(row: $0, section: 0) IndexPath(row: $0, section: 0)
} }
self.statuses.append(contentsOf: newStatuses.map { ($0.id, .unknown) }) self.statuses.append(contentsOf: newStatuses.map { ($0.id, .unknown) })
DispatchQueue.main.async { DispatchQueue.main.async {
UIView.performWithoutAnimation { UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .automatic) self.tableView.insertRows(at: newIndexPaths, with: .automatic)
}
} }
} }
} }
@ -124,15 +112,15 @@ class BookmarksTableViewController: EnhancedTableViewController {
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let cellConfig = (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration() let cellConfig = (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else { guard let status = mastodonController.cache.status(for: statuses[indexPath.row].id) else {
return cellConfig return cellConfig
} }
let unbookmarkAction = UIContextualAction(style: .destructive, title: NSLocalizedString("Unbookmark", comment: "unbookmark action title")) { (action, view, completion) in let unbookmarkAction = UIContextualAction(style: .destructive, title: NSLocalizedString("Unbookmark", comment: "unbookmark action title")) { (action, view, completion) in
let request = Status.unbookmark(status.id) let request = Status.unbookmark(status)
self.mastodonController.run(request) { (response) in self.mastodonController.run(request) { (response) in
guard case let .success(newStatus, _) = response else { fatalError() } guard case let .success(newStatus, _) = response else { fatalError() }
self.mastodonController.persistentContainer.addOrUpdate(status: newStatus, incrementReferenceCount: false) self.mastodonController.cache.add(status: newStatus)
self.statuses.remove(at: indexPath.row) self.statuses.remove(at: indexPath.row)
} }
} }
@ -149,6 +137,20 @@ class BookmarksTableViewController: EnhancedTableViewController {
return config return config
} }
override func getSuggestedContextMenuActions(tableView: UITableView, indexPath: IndexPath, point: CGPoint) -> [UIAction] {
guard let status = mastodonController.cache.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)
self.mastodonController.run(request) { (response) in
guard case let .success(newStatus, _) = response else { fatalError() }
self.mastodonController.cache.add(status: newStatus)
self.statuses.remove(at: indexPath.row)
}
})
]
}
} }
extension BookmarksTableViewController: StatusTableViewCellDelegate { extension BookmarksTableViewController: StatusTableViewCellDelegate {
@ -163,7 +165,7 @@ extension BookmarksTableViewController: StatusTableViewCellDelegate {
extension BookmarksTableViewController: UITableViewDataSourcePrefetching { extension BookmarksTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { for indexPath in indexPaths {
guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else { continue } guard let status = mastodonController.cache.status(for: statuses[indexPath.row].id) else { continue }
_ = ImageCache.avatars.get(status.account.avatar, completion: nil) _ = ImageCache.avatars.get(status.account.avatar, completion: nil)
for attachment in status.attachments where attachment.kind == .image { for attachment in status.attachments where attachment.kind == .image {
_ = ImageCache.attachments.get(attachment.url, completion: nil) _ = ImageCache.attachments.get(attachment.url, completion: nil)
@ -173,7 +175,7 @@ extension BookmarksTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { for indexPath in indexPaths {
guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else { continue } guard let status = mastodonController.cache.status(for: statuses[indexPath.row].id) else { continue }
ImageCache.avatars.cancelWithoutCallback(status.account.avatar) ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
for attachment in status.attachments where attachment.kind == .image { for attachment in status.attachments where attachment.kind == .image {
ImageCache.attachments.cancelWithoutCallback(attachment.url) ImageCache.attachments.cancelWithoutCallback(attachment.url)

Some files were not shown because too many files have changed in this diff Show More