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"]
path = Cache
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 Combine
/**
The base Mastodon API client.
@ -26,7 +27,7 @@ public class Client {
public var timeoutInterval: TimeInterval = 60
static let decoder: JSONDecoder = {
lazy var decoder: JSONDecoder = {
let decoder = JSONDecoder()
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
@ -36,16 +37,6 @@ public class Client {
return decoder
}()
static let encoder: JSONEncoder = {
let encoder = JSONEncoder()
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
formatter.timeZone = TimeZone(abbreviation: "UTC")
formatter.locale = Locale(identifier: "en_US_POSIX")
encoder.dateEncodingStrategy = .formatted(formatter)
return encoder
}()
public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
self.baseURL = baseURL
self.accessToken = accessToken
@ -60,24 +51,29 @@ public class Client {
let task = session.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(.networkError(error)))
completion(.failure(error))
return
}
guard let data = data,
let response = response as? HTTPURLResponse else {
completion(.failure(.invalidResponse))
completion(.failure(Error.invalidResponse))
return
}
guard response.statusCode == 200 else {
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
let error: Error = mastodonError.flatMap { .mastodonError($0.description) } ?? .unexpectedStatus(response.statusCode)
let mastodonError = try? self.decoder.decode(MastodonError.self, from: data)
let error = mastodonError.flatMap { Error.mastodonError($0.description) } ?? Error.unknownError
completion(.failure(error))
return
}
guard let result = try? Client.decoder.decode(Result.self, from: data) else {
completion(.failure(.invalidModel))
guard let result = try? self.decoder.decode(Result.self, from: data) else {
completion(.failure(Error.invalidModel))
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)
completion(.success(result, pagination))
@ -85,6 +81,43 @@ public class Client {
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? {
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
components.path = request.path
@ -102,7 +135,7 @@ public class Client {
// MARK: - Authorization
public func registerApp(name: String, redirectURI: String, scopes: [Scope], website: URL? = nil, completion: @escaping Callback<RegisteredApplication>) {
let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: ParametersBody([
let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: .parameters([
"client_name" => name,
"redirect_uris" => redirectURI,
"scopes" => scopes.scopeString,
@ -119,7 +152,7 @@ public class Client {
}
public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) {
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: ParametersBody([
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: .parameters([
"client_id" => clientID,
"client_secret" => clientSecret,
"grant_type" => "authorization_code",
@ -178,13 +211,13 @@ public class Client {
}
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
]))
}
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
]))
}
@ -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> {
return Request<Filter>(method: .post, path: "/api/v1/filters", body: ParametersBody([
return Request<Filter>(method: .post, path: "/api/v1/filters", body: .parameters([
"phrase" => phrase,
"irreversible" => irreversible,
"whole_word" => wholeWord,
@ -219,7 +252,7 @@ public class Client {
}
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
@ -232,12 +265,12 @@ public class Client {
}
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
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,
"focus" => focus
], attachment))
@ -269,7 +302,7 @@ public class Client {
}
public static func report(account: Account, statuses: [Status], comment: String) -> Request<Report> {
return Request<Report>(method: .post, path: "/api/v1/reports", body: ParametersBody([
return Request<Report>(method: .post, path: "/api/v1/reports", body: .parameters([
"account_id" => account.id,
"comment" => comment
] + "status_ids" => statuses.map { $0.id }))
@ -297,7 +330,7 @@ public class Client {
spoilerText: String? = nil,
visibility: Status.Visibility? = nil,
language: String? = nil) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
return Request<Status>(method: .post, path: "/api/v1/statuses", body: .parameters([
"status" => text,
"content_type" => contentType.mimeType,
"in_reply_to_id" => inReplyTo,
@ -324,32 +357,12 @@ public class Client {
}
extension Client {
public enum Error: LocalizedError {
case networkError(Swift.Error)
case unexpectedStatus(Int)
public enum Error: Swift.Error {
case unknownError
case invalidRequest
case invalidResponse
case invalidModel
case mastodonError(String)
public var localizedDescription: String {
switch self {
case .networkError(let error):
return "Network Error: \(error.localizedDescription)"
// todo: support more status codes
case .unexpectedStatus(413):
return "HTTP 413: Payload Too Large"
case .unexpectedStatus(let code):
return "HTTP Code \(code)"
case .invalidRequest:
return "Invalid Request"
case .invalidResponse:
return "Invalid Response"
case .invalidModel:
return "Invalid Model"
case .mastodonError(let error):
return "Server Error: \(error)"
}
}
case urlError(URLError)
}
}

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
public final class Account: AccountProtocol, Decodable {
public class Account: Decodable {
public let id: String
public let username: String
public let acct: String
@ -27,7 +27,7 @@ public final class Account: AccountProtocol, Decodable {
public private(set) var emojis: [Emoji]
public let moved: Bool?
public let movedTo: Account?
public let fields: [Field]
public let fields: [Field]?
public let bot: Bool?
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.avatarStatic = try container.decode(URL.self, forKey: .avatarStatic)
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.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)
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> {
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
]))
}

View File

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

View File

@ -8,7 +8,7 @@
import Foundation
public class Emoji: Codable {
public class Emoji: Decodable {
public let shortcode: String
public let url: 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> {
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),
"irreversible" => (irreversible ?? filter.irreversible),
"whole_word" => (wholeWord ?? filter.wholeWord),

View File

@ -8,7 +8,7 @@
import Foundation
public class List: Decodable, Equatable, Hashable {
public class List: Decodable {
public let id: String
public let title: String
@ -16,14 +16,6 @@ public class List: Decodable, Equatable, Hashable {
return .list(id: id)
}
public static func ==(lhs: List, rhs: List) -> Bool {
return lhs.id == rhs.id
}
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts")
request.range = range
@ -31,7 +23,7 @@ public class List: Decodable, Equatable, Hashable {
}
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> {
@ -39,13 +31,13 @@ public class List: Decodable, Equatable, Hashable {
}
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
))
}
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
))
}

View File

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

View File

@ -15,26 +15,8 @@ public class Notification: Decodable {
public let account: Account
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> {
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
]))
}
@ -55,7 +37,6 @@ extension Notification {
case favourite
case follow
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
public final class Status: /*StatusProtocol,*/ Decodable {
public class Status: Decodable {
public let id: String
public let uri: String
public let url: URL?
@ -36,26 +36,23 @@ public final class Status: /*StatusProtocol,*/ Decodable {
public let language: String?
public let pinned: Bool?
public let bookmarked: Bool?
public let card: Card?
public var applicationName: String? { application?.name }
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(statusID)/context")
public static func getContext(_ status: Status) -> Request<ConversationContext> {
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(status.id)/context")
}
public static func getCard(_ status: Status) -> Request<Card> {
return Request<Card>(method: .get, path: "/api/v1/statuses/\(status.id)/card")
}
public static func getFavourites(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/favourited_by")
public static func getFavourites(_ status: Status, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/favourited_by")
request.range = range
return request
}
public static func getReblogs(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/reblogged_by")
public static func getReblogs(_ status: Status, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/reblogged_by")
request.range = range
return request
}
@ -64,44 +61,44 @@ public final class Status: /*StatusProtocol,*/ Decodable {
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(status.id)")
}
public static func reblog(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/reblog")
public static func reblog(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/reblog")
}
public static func unreblog(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unreblog")
public static func unreblog(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unreblog")
}
public static func favourite(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/favourite")
public static func favourite(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/favourite")
}
public static func unfavourite(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unfavourite")
public static func unfavourite(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unfavourite")
}
public static func pin(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/pin")
public static func pin(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/pin")
}
public static func unpin(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unpin")
public static func unpin(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unpin")
}
public static func bookmark(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/bookmark")
public static func bookmark(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/bookmark")
}
public static func unbookmark(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unbookmark")
public static func unbookmark(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unbookmark")
}
public static func muteConversation(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/mute")
public static func muteConversation(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/mute")
}
public static func unmuteConversation(_ statusID: String) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unmute")
public static func unmuteConversation(_ status: Status) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unmute")
}
private enum CodingKeys: String, CodingKey {
@ -131,7 +128,6 @@ public final class Status: /*StatusProtocol,*/ Decodable {
case language
case pinned
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
protocol Body {
var mimeType: String? { get }
var data: Data? { get }
enum Body {
case parameters([Parameter]?)
case formData([Parameter]?, FormAttachment?)
case empty
}
struct EmptyBody: Body {
var mimeType: String? { nil }
var data: Data? { nil }
}
struct ParametersBody: Body {
let parameters: [Parameter]?
init(_ parmaeters: [Parameter]?) {
self.parameters = parmaeters
}
var mimeType: String? {
if parameters == nil || parameters!.isEmpty {
return nil
}
return "application/x-www-form-urlencoded; charset=utf-8"
}
extension Body {
private static let boundary: String = "PachydermBoundary"
var data: Data? {
switch self {
case let .parameters(parameters):
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? {
case let .formData(parameters, attachment):
var data = Data()
parameters?.forEach { param in
guard let value = param.value else { return }
data.append("--\(FormDataBody.boundary)\r\n")
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("--\(FormDataBody.boundary)\r\n")
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("--\(FormDataBody.boundary)--\r\n")
data.append("--\(Body.boundary)--\r\n")
return data
case .empty:
return nil
}
}
struct JsonBody<T: Encodable>: Body {
let value: T
init(_ value: T) {
self.value = value
}
var mimeType: String? { "application/json" }
var data: Data? { try? Client.encoder.encode(value) }
var mimeType: String? {
switch self {
case let .parameters(parameters):
if parameters == nil {
return nil
}
return "application/x-www-form-urlencoded; charset=utf-8"
case let .formData(parameters, attachment):
if parameters == nil && attachment == nil {
return nil
}
return "multipart/form-data; boundary=\(Body.boundary)"
case .empty:
return nil
}
}
}

View File

@ -14,7 +14,7 @@ public struct Request<ResultType: Decodable> {
let body: Body
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.path = path
self.body = body

View File

@ -10,5 +10,5 @@ import Foundation
public enum Response<Result: Decodable> {
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 task = URLSession.shared.dataTask(with: request) { (data, response, error) in
if let error = error {
completion(.failure(.networkError(error)))
completion(.failure(error))
return
}
guard let data = data,
let response = response as? HTTPURLResponse else {
completion(.failure(.invalidResponse))
completion(.failure(Client.Error.invalidResponse))
return
}
guard response.statusCode == 200 else {
completion(.failure(.unexpectedStatus(response.statusCode)))
completion(.failure(Client.Error.unknownError))
return
}
guard let result = try? decoder.decode([Instance].self, from: data) else {

View File

@ -9,14 +9,14 @@
import Foundation
public class NotificationGroup {
public let notifications: [Notification]
public let notificationIDs: [String]
public let id: String
public let kind: Notification.Kind
public let statusState: StatusState?
init?(notifications: [Notification]) {
guard !notifications.isEmpty else { return nil }
self.notifications = notifications
self.notificationIDs = notifications.map { $0.id }
self.id = notifications.first!.id
self.kind = notifications.first!.kind
if kind == .mention {
@ -27,24 +27,18 @@ public class NotificationGroup {
}
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
var groups = [[Notification]]()
for notification in notifications {
if allowedTypes.contains(notification.kind) {
if let lastGroup = groups.last, let firstNotification = lastGroup.first, firstNotification.kind == notification.kind, firstNotification.status?.id == notification.status?.id {
groups[groups.count - 1].append(notification)
continue
} else if groups.count >= 2 {
let secondToLastGroup = groups[groups.count - 2]
if allowedTypes.contains(groups[groups.count - 1][0].kind), let firstNotification = secondToLastGroup.first, firstNotification.kind == notification.kind, firstNotification.status?.id == notification.status?.id {
groups[groups.count - 2].append(notification)
continue
}
}
}
return notifications.reduce(into: [[Notification]]()) { (groups, notification) in
if allowedTypes.contains(notification.kind),
let lastGroup = groups.last,
let firstStatus = lastGroup.first,
firstStatus.kind == notification.kind,
firstStatus.status?.id == notification.status?.id {
groups[groups.count - 1].append(notification)
} else {
groups.append([notification])
}
return groups.map {
}.map {
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"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D6D4DDCB212518A000E1C4BB"
BuildableName = "Tusker.app"
BlueprintName = "Tusker"
ReferencedContainer = "container:Tusker.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO">
@ -83,12 +92,6 @@
ReferencedContainer = "container:Tusker.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"

View File

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

View File

@ -1,32 +1,14 @@
{
"object": {
"pins": [
{
"package": "PLCrashReporter",
"repositoryURL": "https://github.com/microsoft/plcrashreporter",
"state": {
"branch": null,
"revision": "6b7ca9a2faad6ea990ff60b0a3ee4fdf3db59150",
"version": "1.7.2"
}
},
{
"package": "SheetController",
"repositoryURL": "https://git.shadowfacts.net/shadowfacts/SheetController.git",
"state": {
"branch": "master",
"revision": "aa0f5192eaf19d01c89dbfa9ec5878a700376f23",
"revision": "6ee1ad24ec8620f5c17416d6141643f0787708ba",
"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 Pachyderm
class AccountActivity: MastodonActivity {
@ -14,17 +15,17 @@ class AccountActivity: MastodonActivity {
return .action
}
var account: AccountMO?
var account: Account?
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
for case is AccountMO in activityItems {
for case is Account in activityItems {
return true
}
return false
}
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
return
}

View File

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

View File

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

View File

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

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)
}
static func completionHandler(navigator: TuskerNavigationDelegate, url: URL) -> UIActivityViewController.CompletionWithItemsHandler {
static func completionHandler(viewController: UIViewController, url: URL) -> UIActivityViewController.CompletionWithItemsHandler {
return { (activityType, _, _, _) in
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() {
guard let status = status else { return }
let request = Status.bookmark(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
let request = Status.bookmark(status)
mastodonController.run(request).then { (status, _) -> Void in
self.mastodonController.cache.add(status: status)
}.catch { (error) -> Void in
print("could not bookmark status: \(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() {
guard let status = status else { return }
let request = Status.pin(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
let request = Status.pin(status)
mastodonController.run(request).then { (status, _) -> Void in
self.mastodonController.cache.add(status: status)
}.catch { (error) -> Void in
print("could not pin status: \(error)")
UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError()
}
}
}
}

View File

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

View File

@ -26,15 +26,12 @@ class UnbookmarkStatusActivity: StatusActivity {
override func perform() {
guard let status = status else { return }
let request = Status.unbookmark(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
let request = Status.unbookmark(status)
mastodonController.run(request).then { (status, _) -> Void in
self.mastodonController.cache.add(status: status)
}.catch { (error) -> Void in
print("could not unbookmark status: \(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() {
guard let status = status else { return }
let request = Status.unpin(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
let request = Status.unpin(status)
mastodonController.run(request).then { (status, _) -> Void in
self.mastodonController.cache.add(status: status)
}.catch { (error) -> Void in
print("could not unpin status: \(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 pinStatus = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).pin_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 CrashReporter
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
static private(set) var crashReporter: PLCrashReporter!
static var pendingCrashReport: PLCrashReport?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
#if !DEBUG
setupCrashReporter()
#endif
AppShortcutItem.createItems(for: application)
DispatchQueue.global(qos: .userInitiated).async {
AudioSessionHelper.disable()
AudioSessionHelper.setDefault()
}
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" : [
{
"filename" : "20x20@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
"size" : "20x20",
"scale" : "2x"
},
{
"filename" : "20x20@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
"size" : "20x20",
"scale" : "3x"
},
{
"filename" : "29x29@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
"size" : "29x29",
"scale" : "2x"
},
{
"filename" : "29x29@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
"size" : "29x29",
"scale" : "3x"
},
{
"filename" : "40x40@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
"size" : "40x40",
"scale" : "2x"
},
{
"filename" : "40x40@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
"size" : "40x40",
"scale" : "3x"
},
{
"filename" : "60x60@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
"size" : "60x60",
"scale" : "2x"
},
{
"filename" : "60x60@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
"size" : "60x60",
"scale" : "3x"
},
{
"filename" : "20x20@1x.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
"size" : "20x20",
"scale" : "1x"
},
{
"filename" : "20x20@2x-1.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
"size" : "20x20",
"scale" : "2x"
},
{
"filename" : "29x29@1x.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
"size" : "29x29",
"scale" : "1x"
},
{
"filename" : "29x29@2x-1.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
"size" : "29x29",
"scale" : "2x"
},
{
"filename" : "40x40@1x.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
"size" : "40x40",
"scale" : "1x"
},
{
"filename" : "40x40@2x-1.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
"size" : "40x40",
"scale" : "2x"
},
{
"filename" : "76x76@1x.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
"size" : "76x76",
"scale" : "1x"
},
{
"filename" : "76x76@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "76x76"
"size" : "76x76",
"scale" : "2x"
},
{
"filename" : "83.5x83.5@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
"size" : "83.5x83.5",
"scale" : "2x"
},
{
"filename" : "1024x1024@1x.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
"size" : "1024x1024",
"scale" : "1x"
}
],
"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)
}
}
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 Cache
import Pachyderm
class ImageCache {
@ -16,9 +17,9 @@ class ImageCache {
static let attachments = ImageCache(name: "Attachments", memoryExpiry: .seconds(60 * 2))
static let emojis = ImageCache(name: "Emojis", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60))
private let cache: Cache<Data>
let cache: Cache<Data>
private var groups = [URL: RequestGroup]()
var requests = [URL: RequestGroup]()
init(name: String, memoryExpiry expiry: Expiry) {
let storage = MemoryStorage<Data>(config: MemoryConfig(expiry: expiry))
@ -43,56 +44,59 @@ class ImageCache {
completion?(data)
return nil
} else {
if let completion = completion, let group = groups[url] {
if let completion = completion, let group = requests[url] {
return group.addCallback(completion)
} else {
let group = RequestGroup(url: url) { (data) in
if let data = data {
let group = RequestGroup(url: url)
let request = group.addCallback(completion)
group.run { (data) in
try? self.cache.setObject(data, forKey: key)
}
self.groups.removeValue(forKey: url)
}
groups[url] = group
let request = group.addCallback(completion)
group.run()
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? {
return try? cache.object(forKey: url.absoluteString)
}
func cancelWithoutCallback(_ url: URL) {
groups[url]?.cancelWithoutCallback()
requests[url]?.cancelWithoutCallback()
}
func reset() throws {
try cache.removeAll()
}
private class RequestGroup {
class RequestGroup {
let url: URL
private let onFinished: (Data?) -> Void
private var task: URLSessionDataTask?
private var requests = [Request]()
init(url: URL, onFinished: @escaping (Data?) -> Void) {
init(url: URL) {
self.url = url
self.onFinished = onFinished
}
deinit {
task?.cancel()
}
func run() {
func run(cache: @escaping (Data) -> Void) {
task = URLSession.shared.dataTask(with: url, completionHandler: { (data, response, error) in
guard error == nil, let data = data else {
self.complete(with: nil)
return
}
cache(data)
self.complete(with: data)
})
task!.resume()
@ -132,12 +136,11 @@ class ImageCache {
callback(data)
}
}
self.onFinished(data)
}
}
class Request {
private weak var group: RequestGroup?
weak var group: RequestGroup?
private(set) var callback: ((Data?) -> Void)?
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 Pachyderm
import Combine
class MastodonController {
@ -30,36 +31,34 @@ class MastodonController {
}
}
static func resetAll() {
all = [:]
}
private let transient: Bool
private(set) lazy var persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient)
private(set) lazy var cache = MastodonCache(mastodonController: self)
let instanceURL: URL
var accountInfo: LocalData.UserAccountInfo?
private(set) var accountInfo: LocalData.UserAccountInfo?
let client: Client!
var account: Account!
var instance: Instance!
var loggedIn: Bool {
accountInfo != nil
}
init(instanceURL: URL, transient: Bool = false) {
init(instanceURL: URL) {
self.instanceURL = instanceURL
self.accountInfo = nil
self.client = Client(baseURL: instanceURL)
self.transient = transient
}
func run<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) {
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) {
guard client.clientID == nil,
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 {
completion?(.success(account))
completion?(account)
} else {
let request = Client.getSelfAccount()
run(request) { response in
switch response {
case let .failure(error):
completion?(.failure(error))
case let .success(account, _):
run(request).then { (account, _) -> Void in
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))
}
}
self.cache.add(account: account)
completion?(account)
}.catch { (error) -> Void in
fatalError("couldn't get own account: \(error)")
}
}
}
func getOwnInstance(completion: ((Instance) -> Void)? = nil) {
if let instance = self.instance {
completion?(instance)
} else {
func getOwnInstance() {
let request = Client.getInstance()
run(request) { (response) in
guard case let .success(instance, _) = response else { fatalError() }
run(request).then { (instance, _) -> Void in
self.instance = instance
completion?(instance)
}
}.catch { (error) -> Void in
fatalError("couldn't get own instance: \(error)")
}
}
}
// 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 Pachyderm
extension AccountMO {
extension Account {
var displayOrUserName: String {
var realDisplayName: String {
if displayName.isEmpty {
return username
} else if Preferences.shared.hideCustomEmojiInUsernames {
return stripCustomEmoji(from: displayName)
} else {
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 func stripCustomEmoji(from string: String) -> String {
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

@ -10,6 +10,31 @@ import Foundation
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) {
let calendar = NSCalendar.current
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 {
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if let presented = presented as? LargeImageAnimatableViewController,
presented.animationImage != nil {
if let presented = presented as? LargeImageViewController,
presented.sourceInfo?.image != nil {
return LargeImageExpandAnimationController()
} else if let presented = presented as? GalleryViewController,
presented.sourcesInfo[presented.startIndex]?.image != nil {
return GalleryExpandAnimationController()
}
return nil
}
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if let dismissed = dismissed as? LargeImageAnimatableViewController,
dismissed.animationImage != nil {
if let dismissed = dismissed as? LargeImageViewController,
dismissed.imageForDismissalAnimation() != nil {
return LargeImageShrinkAnimationController(interactionController: dismissed.dismissInteractionController)
} else if let dismissed = dismissed as? GalleryViewController,
dismissed.imageForDismissalAnimation() != nil {
return GalleryShrinkAnimationController(interactionController: dismissed.dismissInteractionController)
}
return nil
}
@ -30,6 +36,10 @@ extension UIViewController: UIViewControllerTransitioningDelegate {
let interactionController = animator.interactionController,
interactionController.inProgress {
return interactionController
} else if let animator = animator as? GalleryShrinkAnimationController,
let interactionController = animator.interactionController,
interactionController.inProgress {
return interactionController
}
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>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<string>1.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@ -30,9 +30,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.social-networking</string>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
@ -100,16 +98,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</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>
</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() {
if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING") {
defaults = UserDefaults(suiteName: "\(Bundle.main.bundleIdentifier!).uitesting")!
defaults.removePersistentDomain(forName: "\(Bundle.main.bundleIdentifier!).uitesting")
if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING_LOGIN") {
accounts = [
UserAccountInfo(
@ -31,20 +30,7 @@ class LocalData: ObservableObject {
]
}
} else {
defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")!
tryMigrateOldDefaults()
}
}
// TODO: remove me before public beta
private func tryMigrateOldDefaults() {
let old = UserDefaults()
if let accounts = old.array(forKey: accountsKey) as? [[String: String]],
let mostRecentAccount = old.string(forKey: mostRecentAccountKey) {
defaults.setValue(accounts, forKey: accountsKey)
defaults.setValue(mostRecentAccount, forKey: mostRecentAccountKey)
old.removeObject(forKey: accountsKey)
old.removeObject(forKey: mostRecentAccountKey)
defaults = UserDefaults()
}
}
@ -58,10 +44,11 @@ class LocalData: ObservableObject {
let url = URL(string: instanceURL),
let clientId = info["clientID"],
let secret = info["clientSecret"],
let username = info["username"],
let accessToken = info["accessToken"] else {
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 {
return []
@ -69,18 +56,15 @@ class LocalData: ObservableObject {
}
set {
objectWillChange.send()
let array = newValue.map { (info) -> [String: String] in
var res = [
let array = newValue.map { (info) in
return [
"id": info.id,
"instanceURL": info.instanceURL.absoluteString,
"clientID": info.clientID,
"clientSecret": info.clientSecret,
"username": info.username,
"accessToken": info.accessToken
]
if let username = info.username {
res["username"] = username
}
return res
}
defaults.set(array, forKey: accountsKey)
}
@ -101,7 +85,7 @@ class LocalData: ObservableObject {
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
if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) {
accounts.remove(at: index)
@ -113,13 +97,6 @@ class LocalData: ObservableObject {
return info
}
func setUsername(for info: UserAccountInfo, username: String) {
var info = info
info.username = username
removeAccount(info)
accounts.append(info)
}
func removeAccount(_ info: UserAccountInfo) {
accounts.removeAll(where: { $0.id == info.id })
}
@ -151,7 +128,7 @@ extension LocalData {
let instanceURL: URL
let clientID: String
let clientSecret: String
fileprivate(set) var username: String!
let username: String
let accessToken: String
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)
self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme)
self.showRepliesInProfiles = try container.decode(Bool.self, forKey: .showRepliesInProfiles)
self.avatarStyle = try container.decode(AvatarStyle.self, forKey: .avatarStyle)
self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames)
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon)
self.defaultPostVisibility = try container.decode(Status.Visibility.self, forKey: .defaultPostVisibility)
self.automaticallySaveDrafts = try container.decode(Bool.self, forKey: .automaticallySaveDrafts)
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
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.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode)
if container.contains(.expandAllContentWarnings) {
self.expandAllContentWarnings = try container.decode(Bool.self, forKey: .expandAllContentWarnings)
}
if container.contains(.collapseLongPosts) {
self.collapseLongPosts = try container.decode(Bool.self, forKey: .collapseLongPosts)
}
self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
@ -73,25 +63,19 @@ class Preferences: Codable, ObservableObject {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(theme, forKey: .theme)
try container.encode(showRepliesInProfiles, forKey: .showRepliesInProfiles)
try container.encode(avatarStyle, forKey: .avatarStyle)
try container.encode(hideCustomEmojiInUsernames, forKey: .hideCustomEmojiInUsernames)
try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon)
try container.encode(alwaysShowStatusVisibilityIcon, forKey: .alwaysShowStatusVisibilityIcon)
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
try container.encode(automaticallySaveDrafts, forKey: .automaticallySaveDrafts)
try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions)
try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode)
try container.encode(mentionReblogger, forKey: .mentionReblogger)
try container.encode(blurAllMedia, forKey: .blurAllMedia)
try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
try container.encode(openLinksInApps, forKey: .openLinksInApps)
try container.encode(useInAppSafari, forKey: .useInAppSafari)
try container.encode(inAppSafariAutomaticReaderMode, forKey: .inAppSafariAutomaticReaderMode)
try container.encode(expandAllContentWarnings, forKey: .expandAllContentWarnings)
try container.encode(collapseLongPosts, forKey: .collapseLongPosts)
try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts)
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
@ -100,60 +84,46 @@ class Preferences: Codable, ObservableObject {
try container.encode(statusContentType, forKey: .statusContentType)
}
// MARK: Appearance
// MARK: - Appearance
@Published var theme = UIUserInterfaceStyle.unspecified
@Published var showRepliesInProfiles = false
@Published var avatarStyle = AvatarStyle.roundRect
@Published var hideCustomEmojiInUsernames = false
@Published var showIsStatusReplyIcon = false
@Published var alwaysShowStatusVisibilityIcon = false
// MARK: Composing
// MARK: - Behavior
@Published var defaultPostVisibility = Status.Visibility.public
@Published var automaticallySaveDrafts = true
@Published var requireAttachmentDescriptions = false
@Published var contentWarningCopyMode = ContentWarningCopyMode.asIs
@Published var mentionReblogger = false
// MARK: Media
@Published var blurAllMedia = false
@Published var automaticallyPlayGifs = true
// MARK: Behavior
@Published var openLinksInApps = true
@Published var useInAppSafari = true
@Published var inAppSafariAutomaticReaderMode = false
@Published var expandAllContentWarnings = false
@Published var collapseLongPosts = true
// MARK: Digital Wellness
// MARK: - Digital Wellness
@Published var showFavoriteAndReblogCounts = true
@Published var defaultNotificationsMode = NotificationsMode.allNotifications
// MARK: Advanced
// MARK: - Advanced
@Published var silentActions: [String: Permission] = [:]
@Published var statusContentType: StatusContentType = .plain
enum CodingKeys: String, CodingKey {
case theme
case showRepliesInProfiles
case avatarStyle
case hideCustomEmojiInUsernames
case showIsStatusReplyIcon
case alwaysShowStatusVisibilityIcon
case defaultPostVisibility
case automaticallySaveDrafts
case requireAttachmentDescriptions
case contentWarningCopyMode
case mentionReblogger
case blurAllMedia
case automaticallyPlayGifs
case openLinksInApps
case useInAppSafari
case inAppSafariAutomaticReaderMode
case expandAllContentWarnings
case collapseLongPosts
case showFavoriteAndReblogCounts
case defaultNotificationsType

View File

@ -8,8 +8,6 @@
import UIKit
import Pachyderm
import CrashReporter
import MessageUI
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
@ -23,11 +21,15 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
window = UIWindow(windowScene: windowScene)
if let report = AppDelegate.pendingCrashReport {
AppDelegate.pendingCrashReport = nil
handlePendingCrashReport(report, session: session)
if LocalData.shared.onboardingComplete {
if session.mastodonController == nil {
let account = LocalData.shared.getMostRecentAccount()!
session.mastodonController = MastodonController.getForAccount(account)
}
showAppUI()
} else {
showAppOrOnboardingUI(session: session)
showOnboardingUI()
}
window!.makeKeyAndVisible()
@ -80,9 +82,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// 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.
// 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) {
@ -93,9 +92,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func sceneWillResignActive(_ scene: UIScene) {
// 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).
Preferences.save()
DraftsManager.save()
}
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
// to restore the scene back to its current state.
try! scene.session.mastodonController?.persistentContainer.viewContext.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()
}
Preferences.save()
DraftsManager.save()
}
func activateAccount(_ account: LocalData.UserAccountInfo) {
@ -157,17 +128,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
mastodonController.getOwnAccount()
mastodonController.getOwnInstance()
let rootController: UIViewController
#if SDK_IOS_14
if #available(iOS 14.0, *) {
rootController = MainSplitViewController(mastodonController: mastodonController)
} else {
rootController = MainTabBarViewController(mastodonController: mastodonController)
}
#else
rootController = MainTabBarViewController(mastodonController: mastodonController)
#endif
window!.rootViewController = rootController
let tabBarController = MainTabBarViewController(mastodonController: mastodonController)
window!.rootViewController = tabBarController
}
func showOnboardingUI() {
@ -187,11 +149,3 @@ extension SceneDelegate: OnboardingViewControllerDelegate {
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.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 66
tableView.rowHeight = 66
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 Pachyderm
class PendingLargeImageViewController: UIViewController {
class AttachmentViewController: UIViewController {
let url: URL
let cache: ImageCache
let imageDescription: String?
let attachment: Attachment
var largeImageVC: LargeImageViewController?
var loadingVC: LoadingViewController?
var imageRequest: ImageCache.Request?
var attachmentRequest: ImageCache.Request?
private var initialControlsVisible: Bool = true
var controlsVisible: Bool {
@ -37,16 +35,10 @@ class PendingLargeImageViewController: UIViewController {
return largeImageVC
}
init(url: URL, cache: ImageCache, imageDescription: String?) {
self.url = url
self.cache = cache
self.imageDescription = imageDescription
init(attachment: Attachment) {
self.attachment = attachment
super.init(nibName: nil, bundle: nil)
}
convenience init(attachment: Attachment) {
self.init(url: attachment.url, cache: .attachments, imageDescription: attachment.description)
super.init(nibName: "AttachmentViewController", bundle: nil)
}
required init?(coder: NSCoder) {
@ -59,14 +51,14 @@ class PendingLargeImageViewController: UIViewController {
overrideUserInterfaceStyle = .dark
view.backgroundColor = .black
if let data = cache.get(url) {
if let data = ImageCache.attachments.get(attachment.url) {
createLargeImage(data: data)
} else {
loadingVC = LoadingViewController()
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 }
self.imageRequest = nil
self.attachmentRequest = nil
DispatchQueue.main.async {
self.loadingVC?.removeViewAndController()
self.createLargeImage(data: data!)
@ -79,16 +71,16 @@ class PendingLargeImageViewController: UIViewController {
super.didMove(toParent: parent)
if parent == nil {
imageRequest?.cancel()
attachmentRequest?.cancel()
}
}
func createLargeImage(data: Data) {
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!.shrinkGestureEnabled = false
if url.pathExtension == "gif" {
if attachment.url.pathExtension == "gif" {
largeImageVC!.gifData = data
}
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
private var loaded = false
var statuses: [(id: String, state: StatusState)] = []
var newer: RequestRange?
@ -44,19 +42,10 @@ class BookmarksTableViewController: EnhancedTableViewController {
tableView.prefetchDataSource = self
userActivity = UserActivityManager.bookmarksActivity()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if !loaded {
loaded = true
let request = Client.getBookmarks()
mastodonController.run(request) { (response) in
guard case let .success(statuses, pagination) = response else { fatalError() }
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
self.mastodonController.cache.addAll(statuses: statuses)
self.statuses.append(contentsOf: statuses.map { ($0.id, .unknown) })
self.newer = pagination?.newer
self.older = pagination?.older
@ -65,8 +54,8 @@ class BookmarksTableViewController: EnhancedTableViewController {
self.tableView.reloadData()
}
}
}
}
userActivity = UserActivityManager.bookmarksActivity()
}
// MARK: - Table view data source
@ -98,7 +87,7 @@ class BookmarksTableViewController: EnhancedTableViewController {
mastodonController.run(request) { (response) in
guard case let .success(newStatuses, pagination) = response else { fatalError() }
self.older = pagination?.older
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
self.mastodonController.cache.addAll(statuses: newStatuses)
let newIndexPaths = (self.statuses.count..<(self.statuses.count + newStatuses.count)).map {
IndexPath(row: $0, section: 0)
}
@ -111,7 +100,6 @@ class BookmarksTableViewController: EnhancedTableViewController {
}
}
}
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
@ -124,15 +112,15 @@ class BookmarksTableViewController: EnhancedTableViewController {
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
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
}
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
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)
}
}
@ -149,6 +137,20 @@ class BookmarksTableViewController: EnhancedTableViewController {
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 {
@ -163,7 +165,7 @@ extension BookmarksTableViewController: StatusTableViewCellDelegate {
extension BookmarksTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
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)
for attachment in status.attachments where attachment.kind == .image {
_ = ImageCache.attachments.get(attachment.url, completion: nil)
@ -173,7 +175,7 @@ extension BookmarksTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
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)
for attachment in status.attachments where attachment.kind == .image {
ImageCache.attachments.cancelWithoutCallback(attachment.url)

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