Compare commits

...

1 Commits

17 changed files with 497 additions and 136 deletions

View File

@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import Combine
/** /**
The base Mastodon API client. The base Mastodon API client.
@ -80,6 +81,43 @@ public class Client {
task.resume() task.resume()
} }
public func run<Result>(_ request: Request<Result>) -> Promise<(Result, Pagination?)> {
return Promise { (resolve, reject) in
self.run(request) { (response) in
switch response {
case let .success(result, pagination):
resolve((result, pagination))
case let .failure(error):
reject(error)
}
}
}
}
public func run<Result: Decodable>(_ request: Request<Result>) -> AnyPublisher<(Result, Pagination?), Swift.Error> {
guard let request = createURLRequest(request: request) else {
return Fail(error: Error.invalidRequest).eraseToAnyPublisher()
}
return session.dataTaskPublisher(for: request)
.mapError { Error.urlError($0) }
.tryMap {
guard let response = $0.response as? HTTPURLResponse else {
throw Error.invalidResponse
}
guard response.statusCode == 200 else {
if let mastodonError = try? self.decoder.decode(MastodonError.self, from: $0.data) {
throw Error.mastodonError(mastodonError.description)
} else {
throw Error.unknownError
}
}
let result = try self.decoder.decode(Result.self, from: $0.data)
let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
return (result, pagination)
}
.eraseToAnyPublisher()
}
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? { func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil } guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
components.path = request.path components.path = request.path
@ -325,6 +363,6 @@ extension Client {
case invalidResponse case invalidResponse
case invalidModel case invalidModel
case mastodonError(String) case mastodonError(String)
case urlError(URLError)
} }
} }

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

@ -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"
}
}

View File

@ -156,6 +156,8 @@
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */; }; D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */; };
D67C57B221E28FAD00C3118B /* ComposeStatusReplyView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D67C57B121E28FAD00C3118B /* ComposeStatusReplyView.xib */; }; D67C57B221E28FAD00C3118B /* ComposeStatusReplyView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D67C57B121E28FAD00C3118B /* ComposeStatusReplyView.xib */; };
D67C57B421E2910700C3118B /* ComposeStatusReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57B321E2910700C3118B /* ComposeStatusReplyView.swift */; }; D67C57B421E2910700C3118B /* ComposeStatusReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57B321E2910700C3118B /* ComposeStatusReplyView.swift */; };
D683418623F79BFC00D06703 /* Promise.swift in Sources */ = {isa = PBXBuildFile; fileRef = D683418523F79BFC00D06703 /* Promise.swift */; };
D683418A23F7A3BB00D06703 /* PromiseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D683418923F7A3BB00D06703 /* PromiseTests.swift */; };
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */; }; D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */; };
D6945C2F23AC47C3005C403C /* SavedDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6945C2E23AC47C3005C403C /* SavedDataManager.swift */; }; D6945C2F23AC47C3005C403C /* SavedDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6945C2E23AC47C3005C403C /* SavedDataManager.swift */; };
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */; }; D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */; };
@ -431,6 +433,8 @@
D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Uniques.swift"; sourceTree = "<group>"; }; D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Uniques.swift"; sourceTree = "<group>"; };
D67C57B121E28FAD00C3118B /* ComposeStatusReplyView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ComposeStatusReplyView.xib; sourceTree = "<group>"; }; D67C57B121E28FAD00C3118B /* ComposeStatusReplyView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ComposeStatusReplyView.xib; sourceTree = "<group>"; };
D67C57B321E2910700C3118B /* ComposeStatusReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusReplyView.swift; sourceTree = "<group>"; }; D67C57B321E2910700C3118B /* ComposeStatusReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusReplyView.swift; sourceTree = "<group>"; };
D683418523F79BFC00D06703 /* Promise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Promise.swift; sourceTree = "<group>"; };
D683418923F7A3BB00D06703 /* PromiseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromiseTests.swift; sourceTree = "<group>"; };
D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.swift; sourceTree = "<group>"; }; D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.swift; sourceTree = "<group>"; };
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedDataManager.swift; sourceTree = "<group>"; }; D6945C2E23AC47C3005C403C /* SavedDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedDataManager.swift; sourceTree = "<group>"; };
D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = "<group>"; }; D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = "<group>"; };
@ -594,6 +598,7 @@
D61099AD2144B0CC00432DC2 /* Pachyderm.h */, D61099AD2144B0CC00432DC2 /* Pachyderm.h */,
D61099AE2144B0CC00432DC2 /* Info.plist */, D61099AE2144B0CC00432DC2 /* Info.plist */,
D61099C82144B13C00432DC2 /* Client.swift */, D61099C82144B13C00432DC2 /* Client.swift */,
D683418523F79BFC00D06703 /* Promise.swift */,
D6109A0A2145953C00432DC2 /* ClientModel.swift */, D6109A0A2145953C00432DC2 /* ClientModel.swift */,
D6A3BC7223218C6E00FD64D5 /* Utilities */, D6A3BC7223218C6E00FD64D5 /* Utilities */,
D61099D72144B74500432DC2 /* Extensions */, D61099D72144B74500432DC2 /* Extensions */,
@ -609,6 +614,7 @@
children = ( children = (
D61099BA2144B0CC00432DC2 /* PachydermTests.swift */, D61099BA2144B0CC00432DC2 /* PachydermTests.swift */,
D6E6F26421604242006A8599 /* CharacterCounterTests.swift */, D6E6F26421604242006A8599 /* CharacterCounterTests.swift */,
D683418923F7A3BB00D06703 /* PromiseTests.swift */,
D61099BC2144B0CC00432DC2 /* Info.plist */, D61099BC2144B0CC00432DC2 /* Info.plist */,
); );
path = PachydermTests; path = PachydermTests;
@ -1537,6 +1543,7 @@
D61099E92145658300432DC2 /* Card.swift in Sources */, D61099E92145658300432DC2 /* Card.swift in Sources */,
D61099F32145688600432DC2 /* Mention.swift in Sources */, D61099F32145688600432DC2 /* Mention.swift in Sources */,
D6109A0F21459B6900432DC2 /* Pagination.swift in Sources */, D6109A0F21459B6900432DC2 /* Pagination.swift in Sources */,
D683418623F79BFC00D06703 /* Promise.swift in Sources */,
D6109A032145722C00432DC2 /* RegisteredApplication.swift in Sources */, D6109A032145722C00432DC2 /* RegisteredApplication.swift in Sources */,
D6109A0921458C4A00432DC2 /* Empty.swift in Sources */, D6109A0921458C4A00432DC2 /* Empty.swift in Sources */,
D6285B4F21EA695800FE4B39 /* StatusContentType.swift in Sources */, D6285B4F21EA695800FE4B39 /* StatusContentType.swift in Sources */,
@ -1562,6 +1569,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
D683418A23F7A3BB00D06703 /* PromiseTests.swift in Sources */,
D6E6F26521604242006A8599 /* CharacterCounterTests.swift in Sources */, D6E6F26521604242006A8599 /* CharacterCounterTests.swift in Sources */,
D61099BB2144B0CC00432DC2 /* PachydermTests.swift in Sources */, D61099BB2144B0CC00432DC2 /* PachydermTests.swift in Sources */,
); );

View File

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

View File

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

View File

@ -27,14 +27,11 @@ class BookmarkStatusActivity: StatusActivity {
guard let status = status else { return } guard let status = status else { return }
let request = Status.bookmark(status) let request = Status.bookmark(status)
mastodonController.run(request) { (response) in mastodonController.run(request).then { (status, _) -> Void in
if case let .success(status, _) = response { self.mastodonController.cache.add(status: status)
self.mastodonController.cache.add(status: status) }.catch { (error) -> Void in
} else { print("could not bookmark status: \(error)")
// todo: display error message UINotificationFeedbackGenerator().notificationOccurred(.error)
UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError()
}
} }
} }

View File

@ -26,14 +26,11 @@ class PinStatusActivity: StatusActivity {
guard let status = status else { return } guard let status = status else { return }
let request = Status.pin(status) let request = Status.pin(status)
mastodonController.run(request) { (response) in mastodonController.run(request).then { (status, _) -> Void in
if case let .success(status, _) = response { self.mastodonController.cache.add(status: status)
self.mastodonController.cache.add(status: status) }.catch { (error) -> Void in
} else { print("could not pin status: \(error)")
// todo: display error message UINotificationFeedbackGenerator().notificationOccurred(.error)
UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError()
}
} }
} }
} }

View File

@ -27,14 +27,11 @@ class UnbookmarkStatusActivity: StatusActivity {
guard let status = status else { return } guard let status = status else { return }
let request = Status.unbookmark(status) let request = Status.unbookmark(status)
mastodonController.run(request) { (response) in mastodonController.run(request).then { (status, _) -> Void in
if case let .success(status, _) = response { self.mastodonController.cache.add(status: status)
self.mastodonController.cache.add(status: status) }.catch { (error) -> Void in
} else { print("could not unbookmark status: \(error)")
// todo: display error message UINotificationFeedbackGenerator().notificationOccurred(.error)
UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError()
}
} }
} }

View File

@ -26,14 +26,11 @@ class UnpinStatusActivity: StatusActivity {
guard let status = status else { return } guard let status = status else { return }
let request = Status.unpin(status) let request = Status.unpin(status)
mastodonController.run(request) { (response) in mastodonController.run(request).then { (status, _) -> Void in
if case let .success(status, _) = response { self.mastodonController.cache.add(status: status)
self.mastodonController.cache.add(status: status) }.catch { (error) -> Void in
} else { print("could not unpin status: \(error)")
// todo: display error message UINotificationFeedbackGenerator().notificationOccurred(.error)
UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError()
}
} }
} }
} }

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Cache import Cache
import Pachyderm
class ImageCache { class ImageCache {
@ -56,6 +57,18 @@ class ImageCache {
} }
} }
func get(_ url: URL) -> Promise<Data> {
return Promise<Data> { (resolve, reject) in
_ = self.get(url) { (data) in
if let data = data {
resolve(data)
} else {
reject(Error.unknown)
}
}
}
}
func get(_ url: URL) -> Data? { func get(_ url: URL) -> Data? {
return try? cache.object(forKey: url.absoluteString) return try? cache.object(forKey: url.absoluteString)
} }
@ -142,4 +155,8 @@ class ImageCache {
} }
} }
enum Error: Swift.Error {
case unknown
}
} }

View File

@ -8,6 +8,7 @@
import Foundation import Foundation
import Pachyderm import Pachyderm
import Combine
class MastodonController { class MastodonController {
@ -50,6 +51,14 @@ class MastodonController {
client.run(request, completion: completion) client.run(request, completion: completion)
} }
func run<Result: Decodable>(_ request: Request<Result>) -> Promise<(Result, Pagination?)> {
return client.run(request)
}
func run<Result: Decodable>(_ request: Request<Result>) -> AnyPublisher<(Result, Pagination?), Error> {
return client.run(request)
}
func registerApp(completion: @escaping (_ clientID: String, _ clientSecret: String) -> Void) { func registerApp(completion: @escaping (_ clientID: String, _ clientSecret: String) -> Void) {
guard client.clientID == nil, guard client.clientID == nil,
client.clientSecret == nil else { client.clientSecret == nil else {
@ -79,20 +88,22 @@ class MastodonController {
completion?(account) completion?(account)
} else { } else {
let request = Client.getSelfAccount() let request = Client.getSelfAccount()
run(request) { response in run(request).then { (account, _) -> Void in
guard case let .success(account, _) = response else { fatalError() }
self.account = account self.account = account
self.cache.add(account: account) self.cache.add(account: account)
completion?(account) completion?(account)
}.catch { (error) -> Void in
fatalError("couldn't get own account: \(error)")
} }
} }
} }
func getOwnInstance() { func getOwnInstance() {
let request = Client.getInstance() let request = Client.getInstance()
run(request) { (response) in run(request).then { (instance, _) -> Void in
guard case let .success(instance, _) = response else { fatalError() }
self.instance = instance self.instance = instance
}.catch { (error) -> Void in
fatalError("couldn't get own instance: \(error)")
} }
} }

View File

@ -57,6 +57,16 @@ class MastodonCache {
} }
} }
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) { func add(status: Status) {
set(status: status, for: status.id) set(status: status, for: status.id)
} }
@ -90,6 +100,16 @@ class MastodonCache {
} }
} }
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) { func add(account: Account) {
set(account: account, for: account.id) set(account: account, for: account.id)
} }
@ -123,6 +143,16 @@ class MastodonCache {
} }
} }
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) { func add(relationship: Relationship) {
set(relationship: relationship, id: relationship.id) set(relationship: relationship, id: relationship.id)
} }

View File

@ -490,71 +490,52 @@ class ComposeViewController: UIViewController {
} }
let sensitive = contentWarning != nil let sensitive = contentWarning != nil
let visibility = self.visibility! let visibility = self.visibility!
postProgressView.steps = 2 + (selectedAttachments.count * 2) // 2 steps (request data, then upload) for each attachment
postProgressView.currentStep = 1
let group = DispatchGroup() let attachmentPromises = selectedAttachments.map { (attachment) -> Promise<Attachment> in
let view = attachmentsStackView.arrangedSubviews.first { ($0 as! ComposeMediaView).attachment == attachment } as! ComposeMediaView
var attachments: [Attachment?] = [] let description = view.description
for compAttachment in selectedAttachments {
let index = attachments.count return attachment.getData().then { (data, mimeType) -> Promise<(Attachment, Pagination?)> in
attachments.append(nil)
let mediaView = attachmentsStackView.arrangedSubviews[index] as! ComposeMediaView
let description = mediaView.descriptionTextView.text
group.enter()
compAttachment.getData { (data, mimeType) in
self.postProgressView.step()
let request = Client.upload(attachment: FormAttachment(mimeType: mimeType, data: data, fileName: "file"), description: description) let request = Client.upload(attachment: FormAttachment(mimeType: mimeType, data: data, fileName: "file"), description: description)
self.mastodonController.run(request) { (response) in return self.mastodonController.run(request)
guard case let .success(attachment, _) = response else { fatalError() } }.then { (attachment, _) -> Attachment in
self.postProgressView.step()
attachments[index] = attachment return attachment
self.postProgressView.step()
group.leave()
}
} }
} }
postProgressView.steps = 2 + (attachments.count * 2) // 2 steps (request data, then upload) for each attachment Promise<[Attachment]>.all(attachmentPromises).then { (attachments) -> Promise<(Status, Pagination?)> in
postProgressView.currentStep = 1
group.notify(queue: .main) {
let attachments = attachments.compactMap { $0 }
let request = Client.createStatus(text: text, let request = Client.createStatus(text: text,
contentType: Preferences.shared.statusContentType, contentType: Preferences.shared.statusContentType,
inReplyTo: self.inReplyToID, inReplyTo: self.inReplyToID,
media: attachments, media: attachments,
sensitive: sensitive, sensitive: sensitive,
spoilerText: contentWarning, spoilerText: contentWarning,
visibility: visibility, visibility: visibility,
language: nil) language: nil)
self.mastodonController.run(request) { (response) in return self.mastodonController.run(request)
guard case let .success(status, _) = response else { fatalError() } }.then { (status, _) -> Status in
self.postedStatus = status self.postedStatus = status
self.mastodonController.cache.add(status: status) self.mastodonController.cache.add(status: status)
if let draft = self.currentDraft { if let draft = self.currentDraft {
DraftsManager.shared.remove(draft) DraftsManager.shared.remove(draft)
}
DispatchQueue.main.async {
self.postProgressView.step()
self.dismiss(animated: true)
let conversationVC = ConversationTableViewController(for: status.id, mastodonController: self.mastodonController)
self.show(conversationVC, sender: self)
self.xcbSession?.complete(with: .success, additionalData: [
"statusURL": status.url?.absoluteString,
"statusURI": status.uri
])
}
} }
return status
}.handle(on: DispatchQueue.main).then { (status) in
self.postProgressView.step()
self.dismiss(animated: true)
self.xcbSession?.complete(with: .success, additionalData: [
"statusURL": status.url?.absoluteString,
"statusURI": status.uri
])
}.catch { (error) -> Void in
fatalError("couldn't create post: \(error)")
} }
} }

View File

@ -9,6 +9,7 @@
import UIKit import UIKit
import Photos import Photos
import MobileCoreServices import MobileCoreServices
import Pachyderm
enum CompositionAttachment { enum CompositionAttachment {
case asset(PHAsset) case asset(PHAsset)
@ -93,6 +94,14 @@ enum CompositionAttachment {
} }
} }
func getData() -> Promise<(Data, String)> {
return Promise { (resolve, reject) in
self.getData { (data, mimeType) in
resolve((data, mimeType))
}
}
}
private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Data, String) -> Void) { private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Data, String) -> Void) {
session.outputFileType = .mp4 session.outputFileType = .mp4
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4") session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")

View File

@ -187,16 +187,11 @@ class NotificationsTableViewController: EnhancedTableViewController {
} }
func dismissNotificationsInGroup(at indexPath: IndexPath, completion: (() -> Void)? = nil) { func dismissNotificationsInGroup(at indexPath: IndexPath, completion: (() -> Void)? = nil) {
let group = DispatchGroup() let dismissPromises = groups[indexPath.row].notificationIDs.map { (id) -> Promise<(Empty, Pagination?)> in
groups[indexPath.row].notificationIDs let req = Pachyderm.Notification.dismiss(id: id)
.map(Pachyderm.Notification.dismiss(id:)) return self.mastodonController.run(req)
.forEach { (request) in
group.enter()
mastodonController.run(request) { (response) in
group.leave()
}
} }
group.notify(queue: .main) { Promise<[(Empty, Pagination?)]>.all(dismissPromises).handle(on: .main).then { (_) in
self.groups.remove(at: indexPath.row) self.groups.remove(at: indexPath.row)
self.tableView.deleteRows(at: [indexPath], with: .automatic) self.tableView.deleteRows(at: [indexPath], with: .automatic)
completion?() completion?()

View File

@ -43,33 +43,27 @@ class ContentTextView: LinkTextView {
func setEmojis(_ emojis: [Emoji]) { func setEmojis(_ emojis: [Emoji]) {
guard !emojis.isEmpty else { return } guard !emojis.isEmpty else { return }
let emojiImages = CachedDictionary<UIImage>(name: "ContentTextView Emoji Images") let emojiPromises = emojis.map { (emoji) -> Promise<(String, UIImage)> in
ImageCache.emojis.get(emoji.url).then { (data) -> Promise<(String, UIImage)> in
let group = DispatchGroup() if let image = UIImage(data: data) {
return Promise<(String, UIImage)>.resolve((emoji.shortcode, image))
for emoji in emojis { } else {
group.enter() return Promise<(String, UIImage)>.reject(ImageCache.Error.unknown)
_ = ImageCache.emojis.get(emoji.url) { (data) in
defer { group.leave() }
guard let data = data, let image = UIImage(data: data) else {
return
} }
emojiImages[emoji.shortcode] = image
} }
} }
Promise<[(String, UIImage)]>.all(emojiPromises).then { (emojis) in
group.notify(queue: .main) {
let mutAttrString = NSMutableAttributedString(attributedString: self.attributedText!) let mutAttrString = NSMutableAttributedString(attributedString: self.attributedText!)
let string = mutAttrString.string let string = mutAttrString.string
let matches = emojiRegex.matches(in: string, options: [], range: mutAttrString.fullRange) let matches = emojiRegex.matches(in: string, options: [], range: mutAttrString.fullRange)
// replaces the emojis started from the end of the string as to not alter the indexes of the other emojis // replaces emojis starting from the end of the string as to not alter the indices of earlier emojis
for match in matches.reversed() { for match in matches.reversed() {
let shortcode = (string as NSString).substring(with: match.range(at: 1)) let shortcode = (string as NSString).substring(with: match.range(at: 1))
guard let emojiImage = emojiImages[shortcode] else { guard let emojiImage = emojis.first(where: { $0.0 == shortcode }) else {
continue continue
} }
let attachment = self.createEmojiTextAttachment(image: emojiImage, index: match.range.location) let attachment = self.createEmojiTextAttachment(image: emojiImage.1, index: match.range.location)
let attachmentStr = NSAttributedString(attachment: attachment) let attachmentStr = NSAttributedString(attachment: attachment)
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr) mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
} }