From 279b7868e32b746b660ed085e23bde4376115243 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 18 Feb 2020 22:08:46 -0500 Subject: [PATCH] Test with simple swift promises See https://shadowfacts.net/2020/simple-swift-promises/ --- Pachyderm/Client.swift | 40 ++++- Pachyderm/Promise.swift | 170 ++++++++++++++++++ PachydermTests/PromiseTests.swift | 126 +++++++++++++ Tusker.xcodeproj/project.pbxproj | 8 + .../FollowAccountActivity.swift | 13 +- .../UnfollowAccountActivity.swift | 13 +- .../BookmarkStatusActivity.swift | 13 +- .../Status Activities/PinStatusActivity.swift | 13 +- .../UnbookmarkStatusActivity.swift | 13 +- .../UnpinStatusActivity.swift | 13 +- Tusker/Caching/ImageCache.swift | 17 ++ Tusker/Controllers/MastodonController.swift | 19 +- Tusker/MastodonCache.swift | 30 ++++ .../Compose/ComposeViewController.swift | 97 ++++------ .../Compose/CompositionAttachment.swift | 9 + .../NotificationsTableViewController.swift | 13 +- Tusker/Views/ContentTextView.swift | 26 ++- 17 files changed, 497 insertions(+), 136 deletions(-) create mode 100644 Pachyderm/Promise.swift create mode 100644 PachydermTests/PromiseTests.swift diff --git a/Pachyderm/Client.swift b/Pachyderm/Client.swift index c593b7b3..61b7c167 100644 --- a/Pachyderm/Client.swift +++ b/Pachyderm/Client.swift @@ -7,6 +7,7 @@ // import Foundation +import Combine /** The base Mastodon API client. @@ -80,6 +81,43 @@ public class Client { task.resume() } + public func run(_ request: Request) -> 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(_ request: Request) -> 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(request: Request) -> URLRequest? { guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil } components.path = request.path @@ -325,6 +363,6 @@ extension Client { case invalidResponse case invalidModel case mastodonError(String) - + case urlError(URLError) } } diff --git a/Pachyderm/Promise.swift b/Pachyderm/Promise.swift new file mode 100644 index 00000000..11273482 --- /dev/null +++ b/Pachyderm/Promise.swift @@ -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 { + 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(_ value: Result) -> Promise { + let promise = Promise() + promise.resolve(value) + return promise + } + + static func reject(_ error: Error) -> Promise { + let promise = Promise() + promise.reject(error) + return promise + } + + static func all(_ promises: [Promise], 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(_ resultProvider: @escaping ((Swift.Result) -> 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 { + addHandler(`func`) + return self + } + + func then(_ mapper: @escaping (Result) -> Promise) -> Promise { + let next = Promise() + addHandler { (parentResult) in + let newPromise = mapper(parentResult) + newPromise.addHandler(next.resolve) + newPromise.addCatcher(next.reject) + } + addCatcher(next.reject) + return next + } + + func then(_ mapper: @escaping (Result) -> Next) -> Promise { + let next = Promise() + addHandler { (parentResult) in + let newResult = mapper(parentResult) + next.resolve(newResult) + } + addCatcher(next.reject) + return next + } + + @discardableResult + func `catch`(_ catcher: @escaping (Error) -> Void) -> Promise { + addCatcher(catcher) + return self + } + + func `catch`(_ catcher: @escaping (Error) -> Promise) -> Promise { + let next = Promise() + 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 { + let next = Promise() + addHandler(next.resolve) + addCatcher { (error) in + let newResult = catcher(error) + next.resolve(newResult) + } + return next + } + + func handle(on queue: DispatchQueue) -> Promise { + return self.then { (result) in + return Promise { (resolve, reject) in + queue.async { + resolve(result) + } + } + } + } +} diff --git a/PachydermTests/PromiseTests.swift b/PachydermTests/PromiseTests.swift new file mode 100644 index 00000000..44755510 --- /dev/null +++ b/PachydermTests/PromiseTests.swift @@ -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(_ promise: Promise, _ 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.resolve("blah"), "blah") + } + + func testResolveImmediateMapped() { + let promise = Promise.resolve("foo").then { + "test \($0)" + }.then { + Promise.resolve("\($0) bar") + } + assertResultEqual(promise, "test foo bar") + } + + func testContinueAfterReject() { + let promise = Promise.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 { (resolve, reject) in + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + resolve("blah") + } + } + assertResultEqual(promise, "blah") + } + + func testResolveMappedDelayed() { + let promise = Promise { (resolve, reject) in + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + resolve("foo") + } + }.then { + "\($0) bar" + }.then { (result) in + Promise { (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.resolve("a"), + Promise.resolve("b"), + Promise.resolve("c"), + ]) + assertResultEqual(promise, ["a", "b", "c"]) + } + + func testIntermediateReject() { + let promise = Promise.resolve("foo").then { (_) -> Promise in + Promise.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 { (handler) in + handler(Result.success("asdf")) + } + assertResultEqual(success, "asdf") + let failure = Promise { (handler) in + handler(Result.failure(TestError())) + }.catch { (error) -> String in + "blah" + } + assertResultEqual(failure, "blah") + } + +} + +struct TestError: Error { + var localizedDescription: String { + "test error" + } +} diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index c88b778a..32cf5228 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -156,6 +156,8 @@ D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */; }; D67C57B221E28FAD00C3118B /* ComposeStatusReplyView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D67C57B121E28FAD00C3118B /* ComposeStatusReplyView.xib */; }; 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 */; }; D6945C2F23AC47C3005C403C /* SavedDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6945C2E23AC47C3005C403C /* SavedDataManager.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 = ""; }; D67C57B121E28FAD00C3118B /* ComposeStatusReplyView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ComposeStatusReplyView.xib; sourceTree = ""; }; D67C57B321E2910700C3118B /* ComposeStatusReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusReplyView.swift; sourceTree = ""; }; + D683418523F79BFC00D06703 /* Promise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Promise.swift; sourceTree = ""; }; + D683418923F7A3BB00D06703 /* PromiseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromiseTests.swift; sourceTree = ""; }; D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.swift; sourceTree = ""; }; D6945C2E23AC47C3005C403C /* SavedDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedDataManager.swift; sourceTree = ""; }; D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = ""; }; @@ -594,6 +598,7 @@ D61099AD2144B0CC00432DC2 /* Pachyderm.h */, D61099AE2144B0CC00432DC2 /* Info.plist */, D61099C82144B13C00432DC2 /* Client.swift */, + D683418523F79BFC00D06703 /* Promise.swift */, D6109A0A2145953C00432DC2 /* ClientModel.swift */, D6A3BC7223218C6E00FD64D5 /* Utilities */, D61099D72144B74500432DC2 /* Extensions */, @@ -609,6 +614,7 @@ children = ( D61099BA2144B0CC00432DC2 /* PachydermTests.swift */, D6E6F26421604242006A8599 /* CharacterCounterTests.swift */, + D683418923F7A3BB00D06703 /* PromiseTests.swift */, D61099BC2144B0CC00432DC2 /* Info.plist */, ); path = PachydermTests; @@ -1537,6 +1543,7 @@ D61099E92145658300432DC2 /* Card.swift in Sources */, D61099F32145688600432DC2 /* Mention.swift in Sources */, D6109A0F21459B6900432DC2 /* Pagination.swift in Sources */, + D683418623F79BFC00D06703 /* Promise.swift in Sources */, D6109A032145722C00432DC2 /* RegisteredApplication.swift in Sources */, D6109A0921458C4A00432DC2 /* Empty.swift in Sources */, D6285B4F21EA695800FE4B39 /* StatusContentType.swift in Sources */, @@ -1562,6 +1569,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D683418A23F7A3BB00D06703 /* PromiseTests.swift in Sources */, D6E6F26521604242006A8599 /* CharacterCounterTests.swift in Sources */, D61099BB2144B0CC00432DC2 /* PachydermTests.swift in Sources */, ); diff --git a/Tusker/Activities/Account Activities/FollowAccountActivity.swift b/Tusker/Activities/Account Activities/FollowAccountActivity.swift index ae89aa7e..20e68da0 100644 --- a/Tusker/Activities/Account Activities/FollowAccountActivity.swift +++ b/Tusker/Activities/Account Activities/FollowAccountActivity.swift @@ -28,14 +28,11 @@ class FollowAccountActivity: AccountActivity { UIImpactFeedbackGenerator(style: .medium).impactOccurred() let request = Account.follow(account.id) - mastodonController.run(request) { (response) in - if case let .success(relationship, _) = response { - self.mastodonController.cache.add(relationship: relationship) - } else { - // todo: display error message - UINotificationFeedbackGenerator().notificationOccurred(.error) - fatalError() - } + 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) } } diff --git a/Tusker/Activities/Account Activities/UnfollowAccountActivity.swift b/Tusker/Activities/Account Activities/UnfollowAccountActivity.swift index 493923d7..66c29ebc 100644 --- a/Tusker/Activities/Account Activities/UnfollowAccountActivity.swift +++ b/Tusker/Activities/Account Activities/UnfollowAccountActivity.swift @@ -28,14 +28,11 @@ class UnfollowAccountActivity: AccountActivity { UIImpactFeedbackGenerator(style: .medium).impactOccurred() let request = Account.unfollow(account.id) - mastodonController.run(request) { (response) in - if case let .success(relationship, _) = response { - self.mastodonController.cache.add(relationship: relationship) - } else { - // todo: display error message - UINotificationFeedbackGenerator().notificationOccurred(.error) - fatalError() - } + 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) } } diff --git a/Tusker/Activities/Status Activities/BookmarkStatusActivity.swift b/Tusker/Activities/Status Activities/BookmarkStatusActivity.swift index 585f3471..e891b6f0 100644 --- a/Tusker/Activities/Status Activities/BookmarkStatusActivity.swift +++ b/Tusker/Activities/Status Activities/BookmarkStatusActivity.swift @@ -27,14 +27,11 @@ class BookmarkStatusActivity: StatusActivity { guard let status = status else { return } let request = Status.bookmark(status) - mastodonController.run(request) { (response) in - if case let .success(status, _) = response { - self.mastodonController.cache.add(status: status) - } else { - // todo: display error message - UINotificationFeedbackGenerator().notificationOccurred(.error) - fatalError() - } + 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) } } diff --git a/Tusker/Activities/Status Activities/PinStatusActivity.swift b/Tusker/Activities/Status Activities/PinStatusActivity.swift index 40ef6cfd..2e6882e2 100644 --- a/Tusker/Activities/Status Activities/PinStatusActivity.swift +++ b/Tusker/Activities/Status Activities/PinStatusActivity.swift @@ -26,14 +26,11 @@ class PinStatusActivity: StatusActivity { guard let status = status else { return } let request = Status.pin(status) - mastodonController.run(request) { (response) in - if case let .success(status, _) = response { - self.mastodonController.cache.add(status: status) - } else { - // todo: display error message - UINotificationFeedbackGenerator().notificationOccurred(.error) - fatalError() - } + 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) } } } diff --git a/Tusker/Activities/Status Activities/UnbookmarkStatusActivity.swift b/Tusker/Activities/Status Activities/UnbookmarkStatusActivity.swift index 8cce299b..01b969d3 100644 --- a/Tusker/Activities/Status Activities/UnbookmarkStatusActivity.swift +++ b/Tusker/Activities/Status Activities/UnbookmarkStatusActivity.swift @@ -27,14 +27,11 @@ class UnbookmarkStatusActivity: StatusActivity { guard let status = status else { return } let request = Status.unbookmark(status) - mastodonController.run(request) { (response) in - if case let .success(status, _) = response { - self.mastodonController.cache.add(status: status) - } else { - // todo: display error message - UINotificationFeedbackGenerator().notificationOccurred(.error) - fatalError() - } + 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) } } diff --git a/Tusker/Activities/Status Activities/UnpinStatusActivity.swift b/Tusker/Activities/Status Activities/UnpinStatusActivity.swift index a22df120..3df6eced 100644 --- a/Tusker/Activities/Status Activities/UnpinStatusActivity.swift +++ b/Tusker/Activities/Status Activities/UnpinStatusActivity.swift @@ -26,14 +26,11 @@ class UnpinStatusActivity: StatusActivity { guard let status = status else { return } let request = Status.unpin(status) - mastodonController.run(request) { (response) in - if case let .success(status, _) = response { - self.mastodonController.cache.add(status: status) - } else { - // todo: display error message - UINotificationFeedbackGenerator().notificationOccurred(.error) - fatalError() - } + 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) } } } diff --git a/Tusker/Caching/ImageCache.swift b/Tusker/Caching/ImageCache.swift index 2e04fbd9..e65eeb5a 100644 --- a/Tusker/Caching/ImageCache.swift +++ b/Tusker/Caching/ImageCache.swift @@ -8,6 +8,7 @@ import UIKit import Cache +import Pachyderm class ImageCache { @@ -56,6 +57,18 @@ class ImageCache { } } + func get(_ url: URL) -> Promise { + return Promise { (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) } @@ -142,4 +155,8 @@ class ImageCache { } } + enum Error: Swift.Error { + case unknown + } + } diff --git a/Tusker/Controllers/MastodonController.swift b/Tusker/Controllers/MastodonController.swift index 5e2ae194..a0d4826c 100644 --- a/Tusker/Controllers/MastodonController.swift +++ b/Tusker/Controllers/MastodonController.swift @@ -8,6 +8,7 @@ import Foundation import Pachyderm +import Combine class MastodonController { @@ -50,6 +51,14 @@ class MastodonController { client.run(request, completion: completion) } + func run(_ request: Request) -> Promise<(Result, Pagination?)> { + return client.run(request) + } + + func run(_ request: Request) -> 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 { @@ -79,20 +88,22 @@ class MastodonController { completion?(account) } else { let request = Client.getSelfAccount() - run(request) { response in - guard case let .success(account, _) = response else { fatalError() } + run(request).then { (account, _) -> Void in self.account = account self.cache.add(account: account) completion?(account) + }.catch { (error) -> Void in + fatalError("couldn't get own account: \(error)") } } } 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 + }.catch { (error) -> Void in + fatalError("couldn't get own instance: \(error)") } } diff --git a/Tusker/MastodonCache.swift b/Tusker/MastodonCache.swift index 9aec17c6..19b0113e 100644 --- a/Tusker/MastodonCache.swift +++ b/Tusker/MastodonCache.swift @@ -57,6 +57,16 @@ class MastodonCache { } } + func status(for id: String) -> Promise { + 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) } @@ -90,6 +100,16 @@ class MastodonCache { } } + func account(for id: String) -> Promise { + 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) } @@ -123,6 +143,16 @@ class MastodonCache { } } + func relationship(for id: String) -> Promise { + 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) } diff --git a/Tusker/Screens/Compose/ComposeViewController.swift b/Tusker/Screens/Compose/ComposeViewController.swift index d4382ef9..2b30f6f5 100644 --- a/Tusker/Screens/Compose/ComposeViewController.swift +++ b/Tusker/Screens/Compose/ComposeViewController.swift @@ -490,71 +490,52 @@ class ComposeViewController: UIViewController { } let sensitive = contentWarning != nil 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() - - var attachments: [Attachment?] = [] - for compAttachment in selectedAttachments { - let index = attachments.count - 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 attachmentPromises = selectedAttachments.map { (attachment) -> Promise in + let view = attachmentsStackView.arrangedSubviews.first { ($0 as! ComposeMediaView).attachment == attachment } as! ComposeMediaView + let description = view.description + + return attachment.getData().then { (data, mimeType) -> Promise<(Attachment, Pagination?)> in let request = Client.upload(attachment: FormAttachment(mimeType: mimeType, data: data, fileName: "file"), description: description) - self.mastodonController.run(request) { (response) in - guard case let .success(attachment, _) = response else { fatalError() } - - attachments[index] = attachment - - self.postProgressView.step() - - group.leave() - } + return self.mastodonController.run(request) + }.then { (attachment, _) -> Attachment in + self.postProgressView.step() + return attachment } } - postProgressView.steps = 2 + (attachments.count * 2) // 2 steps (request data, then upload) for each attachment - postProgressView.currentStep = 1 - - group.notify(queue: .main) { - let attachments = attachments.compactMap { $0 } - + Promise<[Attachment]>.all(attachmentPromises).then { (attachments) -> Promise<(Status, Pagination?)> in let request = Client.createStatus(text: text, - contentType: Preferences.shared.statusContentType, - inReplyTo: self.inReplyToID, - media: attachments, - sensitive: sensitive, - spoilerText: contentWarning, - visibility: visibility, - language: nil) - self.mastodonController.run(request) { (response) in - guard case let .success(status, _) = response else { fatalError() } - self.postedStatus = status - self.mastodonController.cache.add(status: status) - - if let draft = self.currentDraft { - 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 - ]) - } + contentType: Preferences.shared.statusContentType, + inReplyTo: self.inReplyToID, + media: attachments, + sensitive: sensitive, + spoilerText: contentWarning, + visibility: visibility, + language: nil) + return self.mastodonController.run(request) + }.then { (status, _) -> Status in + self.postedStatus = status + self.mastodonController.cache.add(status: status) + + if let draft = self.currentDraft { + DraftsManager.shared.remove(draft) } + + 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)") } } diff --git a/Tusker/Screens/Compose/CompositionAttachment.swift b/Tusker/Screens/Compose/CompositionAttachment.swift index 87a8fe35..560616dc 100644 --- a/Tusker/Screens/Compose/CompositionAttachment.swift +++ b/Tusker/Screens/Compose/CompositionAttachment.swift @@ -9,6 +9,7 @@ import UIKit import Photos import MobileCoreServices +import Pachyderm enum CompositionAttachment { 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) { session.outputFileType = .mp4 session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4") diff --git a/Tusker/Screens/Notifications/NotificationsTableViewController.swift b/Tusker/Screens/Notifications/NotificationsTableViewController.swift index 414323f7..f569c195 100644 --- a/Tusker/Screens/Notifications/NotificationsTableViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsTableViewController.swift @@ -187,16 +187,11 @@ class NotificationsTableViewController: EnhancedTableViewController { } func dismissNotificationsInGroup(at indexPath: IndexPath, completion: (() -> Void)? = nil) { - let group = DispatchGroup() - groups[indexPath.row].notificationIDs - .map(Pachyderm.Notification.dismiss(id:)) - .forEach { (request) in - group.enter() - mastodonController.run(request) { (response) in - group.leave() - } + let dismissPromises = groups[indexPath.row].notificationIDs.map { (id) -> Promise<(Empty, Pagination?)> in + let req = Pachyderm.Notification.dismiss(id: id) + return self.mastodonController.run(req) } - group.notify(queue: .main) { + Promise<[(Empty, Pagination?)]>.all(dismissPromises).handle(on: .main).then { (_) in self.groups.remove(at: indexPath.row) self.tableView.deleteRows(at: [indexPath], with: .automatic) completion?() diff --git a/Tusker/Views/ContentTextView.swift b/Tusker/Views/ContentTextView.swift index 36309199..1d0646b4 100644 --- a/Tusker/Views/ContentTextView.swift +++ b/Tusker/Views/ContentTextView.swift @@ -43,33 +43,27 @@ class ContentTextView: LinkTextView { func setEmojis(_ emojis: [Emoji]) { guard !emojis.isEmpty else { return } - let emojiImages = CachedDictionary(name: "ContentTextView Emoji Images") - - let group = DispatchGroup() - - for emoji in emojis { - group.enter() - _ = ImageCache.emojis.get(emoji.url) { (data) in - defer { group.leave() } - guard let data = data, let image = UIImage(data: data) else { - return + let emojiPromises = emojis.map { (emoji) -> Promise<(String, UIImage)> in + ImageCache.emojis.get(emoji.url).then { (data) -> Promise<(String, UIImage)> in + if let image = UIImage(data: data) { + return Promise<(String, UIImage)>.resolve((emoji.shortcode, image)) + } else { + return Promise<(String, UIImage)>.reject(ImageCache.Error.unknown) } - emojiImages[emoji.shortcode] = image } } - - group.notify(queue: .main) { + Promise<[(String, UIImage)]>.all(emojiPromises).then { (emojis) in let mutAttrString = NSMutableAttributedString(attributedString: self.attributedText!) let string = mutAttrString.string 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() { 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 } - 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) mutAttrString.replaceCharacters(in: match.range, with: attachmentStr) }