Browse Source

Replace MastodonKit with Pachyderm

pixelfed
Shadowfacts 1 year ago
parent
commit
1119a861d8
Signed by: Shadowfacts <me@shadowfacts.net> GPG Key ID: 94A5AB95422746E5
68 changed files with 2754 additions and 319 deletions
  1. 0
    3
      .gitmodules
  2. 0
    1
      MastodonKit
  3. 53
    8
      MyPlayground.playground/Contents.swift
  4. 346
    0
      Pachyderm/Client.swift
  5. 39
    0
      Pachyderm/ClientModel.swift
  6. 16
    0
      Pachyderm/Extensions/Data.swift
  7. 22
    0
      Pachyderm/Info.plist
  8. 146
    0
      Pachyderm/Model/Account.swift
  9. 21
    0
      Pachyderm/Model/Application.swift
  10. 100
    0
      Pachyderm/Model/Attachment.swift
  11. 50
    0
      Pachyderm/Model/Card.swift
  12. 26
    0
      Pachyderm/Model/ConversationContext.swift
  13. 32
    0
      Pachyderm/Model/Emoji.swift
  14. 70
    0
      Pachyderm/Model/Filter.swift
  15. 43
    0
      Pachyderm/Model/Hashtag.swift
  16. 60
    0
      Pachyderm/Model/Instance.swift
  17. 53
    0
      Pachyderm/Model/List.swift
  18. 23
    0
      Pachyderm/Model/LoginSettings.swift
  19. 17
    0
      Pachyderm/Model/MastodonError.swift
  20. 25
    0
      Pachyderm/Model/Mention.swift
  21. 48
    0
      Pachyderm/Model/Notification.swift
  22. 26
    0
      Pachyderm/Model/PushSubscription.swift
  23. 21
    0
      Pachyderm/Model/RegisteredApplication.swift
  24. 35
    0
      Pachyderm/Model/Relationship.swift
  25. 21
    0
      Pachyderm/Model/Report.swift
  26. 21
    0
      Pachyderm/Model/Scope.swift
  27. 28
    0
      Pachyderm/Model/SearchResults.swift
  28. 222
    0
      Pachyderm/Model/Status.swift
  29. 40
    0
      Pachyderm/Model/Timeline.swift
  30. 19
    0
      Pachyderm/Pachyderm.h
  31. 63
    0
      Pachyderm/Request/Body.swift
  32. 35
    0
      Pachyderm/Request/FormAttachment.swift
  33. 30
    0
      Pachyderm/Request/Method.swift
  34. 80
    0
      Pachyderm/Request/Parameter.swift
  35. 57
    0
      Pachyderm/Request/Request.swift
  36. 31
    0
      Pachyderm/Request/RequestRange.swift
  37. 13
    0
      Pachyderm/Response/Empty.swift
  38. 73
    0
      Pachyderm/Response/Pagination.swift
  39. 14
    0
      Pachyderm/Response/Response.swift
  40. 22
    0
      PachydermTests/Info.plist
  41. 34
    0
      PachydermTests/PachydermTests.swift
  42. 471
    10
      Tusker.xcodeproj/project.pbxproj
  43. 5
    0
      Tusker.xcodeproj/xcuserdata/shadowfacts.xcuserdatad/xcschemes/xcschememanagement.plist
  44. 0
    3
      Tusker.xcworkspace/contents.xcworkspacedata
  45. 9
    14
      Tusker/Controllers/MastodonController.swift
  46. 1
    1
      Tusker/Extensions/Account+Preferences.swift
  47. 1
    1
      Tusker/Extensions/Mastodon+Equatable.swift
  48. 2
    2
      Tusker/Extensions/UIViewController+Delegates.swift
  49. 2
    6
      Tusker/Extensions/Visibility+Helpers.swift
  50. 2
    2
      Tusker/LocalData.swift
  51. 2
    2
      Tusker/Preferences/Preferences.swift
  52. 16
    23
      Tusker/Screens/Compose/ComposeViewController.swift
  53. 3
    4
      Tusker/Screens/Conversation/ConversationViewController.swift
  54. 2
    2
      Tusker/Screens/Main/MainTabBarViewController.swift
  55. 10
    13
      Tusker/Screens/Notifications/NotificationsTableViewController.swift
  56. 2
    1
      Tusker/Screens/Onboarding/OnboardingViewController.swift
  57. 5
    5
      Tusker/Screens/Preferences/VisibilityTableViewController.swift
  58. 16
    19
      Tusker/Screens/Profile/ProfileTableViewController.swift
  59. 24
    19
      Tusker/Screens/Timeline/TimelineTableViewController.swift
  60. 0
    27
      Tusker/Timeline.swift
  61. 2
    3
      Tusker/Views/AttachmentView.swift
  62. 4
    4
      Tusker/Views/HTMLContentLabel.swift
  63. 20
    24
      Tusker/Views/Notifications/ActionNotificationTableViewCell.swift
  64. 8
    10
      Tusker/Views/Notifications/FollowNotificationTableViewCell.swift
  65. 13
    17
      Tusker/Views/Profile Header/ProfileHeaderTableViewCell.swift
  66. 27
    44
      Tusker/Views/Status/ConversationMainStatusTableViewCell.swift
  67. 28
    45
      Tusker/Views/Status/StatusTableViewCell.swift
  68. 4
    6
      Tusker/Views/StatusContentLabel.swift

+ 0
- 3
.gitmodules View File

@@ -1,6 +1,3 @@
1
-[submodule "MastodonKit"]
2
-	path = MastodonKit
3
-	url = git://github.com/shadowfacts/MastodonKit.git
4 1
 [submodule "SwiftSoup"]
5 2
 	path = SwiftSoup
6 3
 	url = git://github.com/scinfu/SwiftSoup.git

+ 0
- 1
MastodonKit

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

+ 53
- 8
MyPlayground.playground/Contents.swift View File

@@ -1,13 +1,58 @@
1 1
 import UIKit
2 2
 
3
-func test(_ nillable: String?) {
4
-    defer {
5
-        print("defer")
3
+class Client {
4
+    func test<A>(_ thing: A) {
5
+        if var thing = thing as? ClientModel {
6
+            thing.client = self
7
+        } else if var arr = thing as? [ClientModel] {
8
+            arr.client = self
9
+        }
10
+//        } else if let arr = thing as? Array<Any> {
11
+//            for el in arr {
12
+//                if var el = el as? ClientModel {
13
+//                    el.client = self
14
+//                }
15
+//            }
16
+//        }
6 17
     }
7
-    guard let value = nillable else { return }
8
-    print(value)
9 18
 }
10 19
 
11
-test("test")
12
-print("------")
13
-test(nil)
20
+protocol ClientModel {
21
+    var client: Client! { get set }
22
+}
23
+
24
+class Something: ClientModel {
25
+    var client: Client!
26
+}
27
+
28
+extension Array: ClientModel where Element: ClientModel {
29
+    var client: Client! {
30
+        get {
31
+            return first?.client
32
+        }
33
+        set {
34
+            for var el in self {
35
+                el.client = newValue
36
+            }
37
+        }
38
+    }
39
+}
40
+//extension Array: ClientModel where Element == ClientModel {
41
+//    var client: Client! {
42
+//        get {
43
+//            return first?.client
44
+//        }
45
+//        set {
46
+//            for var el in self {
47
+//                el.client = newValue
48
+//            }
49
+//        }
50
+//    }
51
+//}
52
+
53
+var array = [Something(), Something()]
54
+
55
+let client = Client()
56
+client.test(array)
57
+array[0].client
58
+array[1].client

+ 346
- 0
Pachyderm/Client.swift View File

@@ -0,0 +1,346 @@
1
+//
2
+//  Client.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/8/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+/**
12
+ The base Mastodon API client.
13
+ */
14
+public class Client {
15
+    
16
+    public typealias Callback<Result: Decodable> = (Response<Result>) -> Void
17
+    
18
+    let baseURL: URL
19
+    let session: URLSession
20
+    
21
+    public var accessToken: String?
22
+    
23
+    public var appID: String?
24
+    public var clientID: String?
25
+    public var clientSecret: String?
26
+    
27
+    public var timeoutInterval: TimeInterval = 60
28
+    
29
+    lazy var decoder: JSONDecoder = {
30
+        let decoder = JSONDecoder()
31
+        let formatter = DateFormatter()
32
+        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
33
+        formatter.timeZone = TimeZone(abbreviation: "UTC")
34
+        formatter.locale = Locale(identifier: "en_US_POSIX")
35
+        decoder.dateDecodingStrategy = .formatted(formatter)
36
+        return decoder
37
+    }()
38
+    
39
+    public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
40
+        self.baseURL = baseURL
41
+        self.accessToken = accessToken
42
+        self.session = session
43
+    }
44
+    
45
+    // MARK: - Internal Helpers
46
+    func run<Result>(_ request: Request<Result>, completion: @escaping Callback<Result>) {
47
+        guard let request = createURLRequest(request: request) else {
48
+            completion(.failure(Error.invalidRequest))
49
+            return
50
+        }
51
+        
52
+        let task = session.dataTask(with: request) { data, response, error in
53
+            if let error = error {
54
+                completion(.failure(error))
55
+                return
56
+            }
57
+            guard let data = data,
58
+                let response = response as? HTTPURLResponse else {
59
+                    completion(.failure(Error.invalidResponse))
60
+                    return
61
+            }
62
+            guard response.statusCode == 200 else {
63
+                let mastodonError = try? self.decoder.decode(MastodonError.self, from: data)
64
+                let error = mastodonError.flatMap { Error.mastodonError($0.description) } ?? Error.unknownError
65
+                completion(.failure(error))
66
+                return
67
+            }
68
+            guard let result = try? self.decoder.decode(Result.self, from: data) else {
69
+                completion(.failure(Error.invalidModel))
70
+                return
71
+            }
72
+            if var result = result as? ClientModel {
73
+                result.client = self
74
+            } else if var result = result as? [ClientModel] {
75
+                result.client = self
76
+            }
77
+            let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
78
+            
79
+            completion(.success(result, pagination))
80
+        }
81
+        task.resume()
82
+    }
83
+    
84
+    func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
85
+        guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
86
+        components.path = request.path
87
+        components.queryItems = request.queryParameters.queryItems
88
+        guard let url = components.url else { return nil }
89
+        var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval)
90
+        urlRequest.httpMethod = request.method.name
91
+        urlRequest.httpBody = request.body.data
92
+        urlRequest.setValue(request.body.mimeType, forHTTPHeaderField: "Content-Type")
93
+        if let accessToken = accessToken {
94
+            urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
95
+        }
96
+        return urlRequest
97
+    }
98
+    
99
+    // MARK: - Authorization
100
+    public func registerApp(name: String, redirectURI: String, scopes: [Scope], website: URL? = nil, completion: @escaping Callback<RegisteredApplication>) {
101
+        let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: .parameters([
102
+                "client_name" => name,
103
+                "redirect_uris" => redirectURI,
104
+                "scopes" => scopes.scopeString,
105
+                "website" => website?.absoluteString
106
+            ]))
107
+        run(request) { result in
108
+            defer { completion(result) }
109
+            guard case let .success(application, _) = result else { return }
110
+            
111
+            self.appID = application.id
112
+            self.clientID = application.clientID
113
+            self.clientSecret = application.clientSecret
114
+        }
115
+    }
116
+    
117
+    public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) {
118
+        let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: .parameters([
119
+                "client_id" => clientID,
120
+                "client_secret" => clientSecret,
121
+                "grant_type" => "authorization_code",
122
+                "code" => authorizationCode,
123
+                "redirect_uri" => redirectURI
124
+            ]))
125
+        run(request) { result in
126
+            defer { completion(result) }
127
+            guard case let .success(loginSettings, _) = result else { return }
128
+            
129
+            self.accessToken = loginSettings.accessToken
130
+        }
131
+    }
132
+    
133
+    // MARK: - Self
134
+    public func getSelfAccount(completion: @escaping Callback<Account>) {
135
+        let request = Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
136
+        run(request, completion: completion)
137
+    }
138
+    
139
+    public func getFavourites(completion: @escaping Callback<[Status]>) {
140
+        let request = Request<[Status]>(method: .get, path: "/api/v1/favourites")
141
+        run(request, completion: completion)
142
+    }
143
+    
144
+    public func getRelationships(accounts: [Account]? = nil, completion: @escaping Callback<[Relationship]>) {
145
+        let request = Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts?.map { $0.id })
146
+        run(request, completion: completion)
147
+    }
148
+    
149
+    public func getInstance(completion: @escaping Callback<Instance>) {
150
+        let request = Request<Instance>(method: .get, path: "/api/v1/instance")
151
+        run(request, completion: completion)
152
+    }
153
+    
154
+    public func getCustomEmoji(completion: @escaping Callback<[Emoji]>) {
155
+        let request = Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis")
156
+        run(request, completion: completion)
157
+    }
158
+    
159
+    // MARK: - Accounts
160
+    public func getAccount(id: String, completion: @escaping Callback<Account>) {
161
+        let request = Request<Account>(method: .get, path: "/api/v1/accounts/\(id)")
162
+        run(request, completion: completion)
163
+    }
164
+    
165
+    public func searchForAccount(query: String, limit: Int? = nil, following: Bool? = nil, completion: @escaping Callback<[Account]>) {
166
+        let request = Request<[Account]>(method: .get, path: "/api/v1/accounts/search", queryParameters: [
167
+                "q" => query,
168
+                "limit" => limit,
169
+                "following" => following
170
+            ])
171
+        run(request, completion: completion)
172
+    }
173
+    
174
+    // MARK: - Blocks
175
+    public func getBlocks(completion: @escaping Callback<[Account]>) {
176
+        let request = Request<[Account]>(method: .get, path: "/api/v1/blocks")
177
+        run(request, completion: completion)
178
+    }
179
+    
180
+    public func getDomainBlocks(completion: @escaping Callback<[String]>) {
181
+        let request = Request<[String]>(method: .get, path: "api/v1/domain_blocks")
182
+        run(request, completion: completion)
183
+    }
184
+    
185
+    public func block(domain: String, completion: @escaping Callback<Empty>) {
186
+        let request = Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: .parameters([
187
+                "domain" => domain
188
+            ]))
189
+        run(request, completion: completion)
190
+    }
191
+    
192
+    public func unblock(domain: String, completion: @escaping Callback<Empty>) {
193
+        let request = Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: .parameters([
194
+                "domain" => domain
195
+            ]))
196
+        run(request, completion: completion)
197
+    }
198
+    
199
+    // MARK: - Filters
200
+    public func getFilters(completion: @escaping Callback<[Filter]>) {
201
+        let request = Request<[Filter]>(method: .get, path: "/api/v1/filters")
202
+        run(request, completion: completion)
203
+    }
204
+    
205
+    public func createFilter(phrase: String, context: [Filter.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil, completion: @escaping Callback<Filter>) {
206
+        let request = Request<Filter>(method: .post, path: "/api/v1/filters", body: .parameters([
207
+                "phrase" => phrase,
208
+                "irreversible" => irreversible,
209
+                "whole_word" => wholeWord,
210
+                "expires_at" => expiresAt
211
+            ] + "context" => context.contextStrings))
212
+        run(request, completion: completion)
213
+    }
214
+    
215
+    public func getFilter(id: String, completion: @escaping Callback<Filter>) {
216
+        let request = Request<Filter>(method: .get, path: "/api/v1/filters/\(id)")
217
+        run(request, completion: completion)
218
+    }
219
+    
220
+    // MARK: - Follows
221
+    public func getFollowRequests(range: RequestRange = .default, completion: @escaping Callback<[Account]>) {
222
+        var request = Request<[Account]>(method: .get, path: "/api/v1/follow_requests")
223
+        request.range = range
224
+        run(request, completion: completion)
225
+    }
226
+    
227
+    public func getFollowSuggestions(completion: @escaping Callback<[Account]>) {
228
+        let request = Request<[Account]>(method: .get, path: "/api/v1/suggestions")
229
+        run(request, completion: completion)
230
+    }
231
+    
232
+    public func followRemote(acct: String, completion: @escaping Callback<Account>) {
233
+        let request = Request<Account>(method: .post, path: "/api/v1/follows", body: .parameters(["uri" => acct]))
234
+        run(request, completion: completion)
235
+    }
236
+    
237
+    // MARK: - Lists
238
+    public func getLists(completion: @escaping Callback<[List]>) {
239
+        let request = Request<[List]>(method: .get, path: "/api/v1/lists")
240
+        run(request, completion: completion)
241
+    }
242
+    
243
+    public func getList(id: String, completion: @escaping Callback<List>) {
244
+        let request = Request<List>(method: .get, path: "/api/v1/lists/\(id)")
245
+        run(request, completion: completion)
246
+    }
247
+    
248
+    public func createList(title: String, completion: @escaping Callback<List>) {
249
+        let request = Request<List>(method: .post, path: "/api/v1/lists", body: .parameters(["title" => title]))
250
+        run(request, completion: completion)
251
+    }
252
+    
253
+    // MARK: - Media
254
+    public func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil, completion: @escaping Callback<Attachment>) {
255
+        let request = Request<Attachment>(method: .post, path: "/api/v1/media", body: .formData([
256
+                "description" => description,
257
+                "focus" => focus
258
+            ], attachment))
259
+        run(request, completion: completion)
260
+    }
261
+    
262
+    // MARK: - Mutes
263
+    public func getMutes(range: RequestRange, completion: @escaping Callback<[Account]>) {
264
+        var request = Request<[Account]>(method: .get, path: "/api/v1/mutes")
265
+        request.range = range
266
+        run(request, completion: completion)
267
+    }
268
+    
269
+    // MARK: - Notifications
270
+    public func getNotifications(range: RequestRange = .default, completion: @escaping Callback<[Notification]>) {
271
+        var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications")
272
+        request.range = range
273
+        run(request, completion: completion)
274
+    }
275
+    
276
+    public func clearNotifications(completion: @escaping Callback<Empty>) {
277
+        let request = Request<Empty>(method: .post, path: "/api/v1/notifications/clear")
278
+        run(request, completion: completion)
279
+    }
280
+    
281
+    // MARK: - Reports
282
+    public func getReports(completion: @escaping Callback<[Report]>) {
283
+        let request = Request<[Report]>(method: .get, path: "/api/v1/reports")
284
+        run(request, completion: completion)
285
+    }
286
+    
287
+    public func report(account: Account, statuses: [Status], comment: String, completion: @escaping Callback<Report>) {
288
+        let request = Request<Report>(method: .post, path: "/api/v1/reports", body: .parameters([
289
+                "account_id" => account.id,
290
+                "comment" => comment
291
+            ] + "status_ids" => statuses.map { $0.id }))
292
+        run(request, completion: completion)
293
+    }
294
+    
295
+    // MARK: - Search
296
+    public func search(query: String, resolve: Bool? = nil, completion: @escaping Callback<SearchResults>) {
297
+        let request = Request<SearchResults>(method: .get, path: "/api/v2/search", queryParameters: [
298
+                "q" => query,
299
+                "resolve" => resolve
300
+            ])
301
+        run(request, completion: completion)
302
+    }
303
+    
304
+    // MARK: - Statuses
305
+    public func getStatus(id: String, completion: @escaping Callback<Status>) {
306
+        let request = Request<Status>(method: .get, path: "/api/v1/statuses/\(id)")
307
+        run(request, completion: completion)
308
+    }
309
+    
310
+    public func createStatus(text: String,
311
+                             inReplyTo: Status? = nil,
312
+                             media: [Attachment]? = nil,
313
+                             sensitive: Bool? = nil,
314
+                             spoilerText: String? = nil,
315
+                             visiblity: Status.Visibility? = nil,
316
+                             language: String? = nil,
317
+                             completion: @escaping Callback<Status>) {
318
+        let request = Request<Status>(method: .post, path: "/api/v1/statuses", body: .parameters([
319
+                "status" => text,
320
+                "in_reply_to_id" => inReplyTo?.id,
321
+                "sensitive" => sensitive,
322
+                "spoiler_text" => spoilerText,
323
+                "visibility" => visiblity?.rawValue,
324
+                "language" => language
325
+            ] + "media" => media?.map { $0.id }))
326
+        run(request, completion: completion)
327
+    }
328
+    
329
+    // MARK: - Timelines
330
+    public func getStatuses(timeline: Timeline, range: RequestRange = .default, completion: @escaping Callback<[Status]>) {
331
+        let request = timeline.request(range: range)
332
+        run(request, completion: completion)
333
+    }
334
+    
335
+}
336
+
337
+extension Client {
338
+    public enum Error: Swift.Error {
339
+        case unknownError
340
+        case invalidRequest
341
+        case invalidResponse
342
+        case invalidModel
343
+        case mastodonError(String)
344
+        
345
+    }
346
+}

+ 39
- 0
Pachyderm/ClientModel.swift View File

@@ -0,0 +1,39 @@
1
+//
2
+//  ClientModel.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+protocol ClientModel {
12
+    var client: Client! { get set }
13
+}
14
+
15
+extension Array where Element == ClientModel {
16
+    var client: Client! {
17
+        get {
18
+            return first?.client
19
+        }
20
+        set {
21
+            for var el in self {
22
+                el.client = newValue
23
+            }
24
+        }
25
+    }
26
+}
27
+
28
+extension Array where Element: ClientModel {
29
+    var client: Client! {
30
+        get {
31
+            return first?.client
32
+        }
33
+        set {
34
+            for var el in self {
35
+                el.client = newValue
36
+            }
37
+        }
38
+    }
39
+}

+ 16
- 0
Pachyderm/Extensions/Data.swift View File

@@ -0,0 +1,16 @@
1
+//
2
+//  Data.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/8/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+extension Data {
12
+    mutating func append(_ string: String, encoding: String.Encoding = .utf8) {
13
+        guard let data = string.data(using: encoding) else { return }
14
+        append(data)
15
+    }
16
+}

+ 22
- 0
Pachyderm/Info.plist View File

@@ -0,0 +1,22 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+<plist version="1.0">
4
+<dict>
5
+	<key>CFBundleDevelopmentRegion</key>
6
+	<string>$(DEVELOPMENT_LANGUAGE)</string>
7
+	<key>CFBundleExecutable</key>
8
+	<string>$(EXECUTABLE_NAME)</string>
9
+	<key>CFBundleIdentifier</key>
10
+	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
11
+	<key>CFBundleInfoDictionaryVersion</key>
12
+	<string>6.0</string>
13
+	<key>CFBundleName</key>
14
+	<string>$(PRODUCT_NAME)</string>
15
+	<key>CFBundlePackageType</key>
16
+	<string>FMWK</string>
17
+	<key>CFBundleShortVersionString</key>
18
+	<string>1.0</string>
19
+	<key>CFBundleVersion</key>
20
+	<string>$(CURRENT_PROJECT_VERSION)</string>
21
+</dict>
22
+</plist>

+ 146
- 0
Pachyderm/Model/Account.swift View File

@@ -0,0 +1,146 @@
1
+//
2
+//  Account.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/8/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public class Account: Decodable, ClientModel {
12
+    var client: Client! {
13
+        didSet {
14
+            emojis.client = client
15
+        }
16
+    }
17
+    
18
+    public let id: String
19
+    public let username: String
20
+    public let acct: String
21
+    public let displayName: String
22
+    public let locked: Bool
23
+    public let createdAt: Date
24
+    public let followersCount: Int
25
+    public let followingCount: Int
26
+    public let statusesCount: Int
27
+    public let note: String
28
+    public let url: URL
29
+    public let avatar: URL
30
+    public let avatarStatic: URL
31
+    public let header: URL
32
+    public let headerStatic: URL
33
+    public private(set) var emojis: [Emoji]
34
+    public let moved: Bool?
35
+    public let fields: [Field]?
36
+    public let bot: Bool?
37
+    
38
+    public func authorizeFollowRequest(completion: @escaping Client.Callback<Empty>) {
39
+        let request = Request<Empty>(method: .post, path: "/api/v1/follow_requests/\(id)/authorize")
40
+        client.run(request, completion: completion)
41
+    }
42
+    
43
+    public func rejectFollowRequest(completion: @escaping Client.Callback<Empty>) {
44
+        let request = Request<Empty>(method: .post, path: "/api/v1/follow_requests/\(id)/reject")
45
+        client.run(request, completion: completion)
46
+    }
47
+    
48
+    public func removeFromFollowRequests(completion: @escaping Client.Callback<Empty>) {
49
+        let request = Request<Empty>(method: .delete, path: "/api/v1/suggestions/\(id)")
50
+        client.run(request, completion: completion)
51
+    }
52
+    
53
+    public func getFollowers(range: RequestRange = .default, completion: @escaping Client.Callback<[Account]>) {
54
+        var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(id)/followers")
55
+        request.range = range
56
+        client.run(request, completion: completion)
57
+    }
58
+    
59
+    public func getFollowing(range: RequestRange = .default, completion: @escaping Client.Callback<[Account]>) {
60
+        var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(id)/following")
61
+        request.range = range
62
+        client.run(request, completion: completion)
63
+    }
64
+    
65
+    public func getStatuses(range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, completion: @escaping Client.Callback<[Status]>) {
66
+        var request = Request<[Status]>(method: .get, path: "/api/v1/accounts/\(id)/statuses", queryParameters: [
67
+                "only_media" => onlyMedia,
68
+                "pinned" => pinned,
69
+                "exclude_replies" => excludeReplies
70
+            ])
71
+        request.range = range
72
+        client.run(request, completion: completion)
73
+    }
74
+    
75
+    public func follow(completion: @escaping Client.Callback<Relationship>) {
76
+        let request = Request<Relationship>(method: .post, path: "/api/v1/accounts/\(id)/follow")
77
+        client.run(request, completion: completion)
78
+    }
79
+    
80
+    public func unfollow(completion: @escaping Client.Callback<Relationship>) {
81
+        let request = Request<Relationship>(method: .post, path: "/api/v1/accounts/\(id)/unfollow")
82
+        client.run(request, completion: completion)
83
+    }
84
+    
85
+    public func block(completion: @escaping Client.Callback<Relationship>) {
86
+        let request = Request<Relationship>(method: .post, path: "/api/v1/accounts/\(id)/block")
87
+        client.run(request, completion: completion)
88
+    }
89
+    
90
+    public func unblock(completion: @escaping Client.Callback<Relationship>) {
91
+        let request = Request<Relationship>(method: .post, path: "/api/v1/accounts/\(id)/unblock")
92
+        client.run(request, completion: completion)
93
+    }
94
+    
95
+    public func mute(notifications: Bool? = nil, completion: @escaping Client.Callback<Relationship>) {
96
+        let request = Request<Relationship>(method: .post, path: "/api/v1/accounts/\(id)/mute", body: .parameters([
97
+                "notifications" => notifications
98
+            ]))
99
+        client.run(request, completion: completion)
100
+    }
101
+    
102
+    public func unmute(completion: @escaping Client.Callback<Relationship>) {
103
+        let request = Request<Relationship>(method: .post, path: "/api/v1/accounts/\(id)/unmute")
104
+        client.run(request, completion: completion)
105
+    }
106
+    
107
+    public func getLists(completion: @escaping Client.Callback<[List]>) {
108
+        let request = Request<[List]>(method: .get, path: "/api/v1/accounts/\(id)/lists")
109
+        client.run(request, completion: completion)
110
+    }
111
+    
112
+    private enum CodingKeys: String, CodingKey {
113
+        case id
114
+        case username
115
+        case acct
116
+        case displayName = "display_name"
117
+        case locked
118
+        case createdAt = "created_at"
119
+        case followersCount = "followers_count"
120
+        case followingCount = "following_count"
121
+        case statusesCount = "statuses_count"
122
+        case note
123
+        case url
124
+        case avatar
125
+        case avatarStatic = "avatar_static"
126
+        case header
127
+        case headerStatic = "header_static"
128
+        case emojis
129
+        case moved
130
+        case fields
131
+        case bot
132
+    }
133
+}
134
+
135
+extension Account: CustomDebugStringConvertible {
136
+    public var debugDescription: String {
137
+        return "Account(\(id), \(acct))"
138
+    }
139
+}
140
+
141
+extension Account {
142
+    public struct Field: Codable {
143
+        let name: String
144
+        let value: String
145
+    }
146
+}

+ 21
- 0
Pachyderm/Model/Application.swift View File

@@ -0,0 +1,21 @@
1
+//
2
+//  Application.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public class Application: Decodable, ClientModel {
12
+    var client: Client!
13
+    
14
+    public let name: String
15
+    public let website: URL?
16
+    
17
+    private enum CodingKeys: String, CodingKey {
18
+        case name
19
+        case website
20
+    }
21
+}

+ 100
- 0
Pachyderm/Model/Attachment.swift View File

@@ -0,0 +1,100 @@
1
+//
2
+//  Attachment.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public class Attachment: Decodable, ClientModel {
12
+    var client: Client!
13
+    
14
+    public let id: String
15
+    public let kind: Kind
16
+    public let url: URL
17
+    public let remoteURL: URL?
18
+    public let previewURL: URL
19
+    public let textURL: URL?
20
+    public let meta: Metadata?
21
+    public var description: String?
22
+    
23
+    public func update(focus: (Float, Float)?, completion: Client.Callback<Attachment>?) {
24
+        let request = Request<Attachment>(method: .put, path: "/api/v1/media/\(id)", body: .formData([
25
+                "description" => description,
26
+                "focus" => focus
27
+            ], nil))
28
+        client.run(request) { result in
29
+            completion?(result)
30
+        }
31
+    }
32
+    
33
+    private enum CodingKeys: String, CodingKey {
34
+        case id
35
+        case kind = "type"
36
+        case url
37
+        case remoteURL = "remote_url"
38
+        case previewURL = "preview_url"
39
+        case textURL = "text_url"
40
+        case meta
41
+        case description
42
+    }
43
+}
44
+
45
+extension Attachment {
46
+    public enum Kind: String, Decodable {
47
+        case image
48
+        case video
49
+        case gifv
50
+        case audio
51
+        case unknown
52
+    }
53
+}
54
+
55
+extension Attachment {
56
+    public class Metadata: Decodable {
57
+        public let length: String?
58
+        public let duration: Float?
59
+        public let audioEncoding: String?
60
+        public let audioBitrate: String?
61
+        public let audioChannels: String?
62
+        public let fps: Float?
63
+        public let width: Int?
64
+        public let height: Int?
65
+        public let size: String?
66
+        public let aspect: Float?
67
+        
68
+        public let small: ImageMetadata?
69
+        public let original: ImageMetadata?
70
+        
71
+        private enum CodingKeys: String, CodingKey {
72
+            case length
73
+            case duration
74
+            case audioEncoding = "audio_encode"
75
+            case audioBitrate = "audio_bitrate"
76
+            case audioChannels = "audio_channels"
77
+            case fps
78
+            case width
79
+            case height
80
+            case size
81
+            case aspect
82
+            case small
83
+            case original
84
+        }
85
+    }
86
+    
87
+    public class ImageMetadata: Decodable {
88
+        public let width: Int?
89
+        public let height: Int?
90
+        public let size: String?
91
+        public let aspect: Float?
92
+        
93
+        private enum CodingKeys: String, CodingKey {
94
+            case width
95
+            case height
96
+            case size
97
+            case aspect
98
+        }
99
+    }
100
+}

+ 50
- 0
Pachyderm/Model/Card.swift View File

@@ -0,0 +1,50 @@
1
+//
2
+//  Card.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public class Card: Decodable, ClientModel {
12
+    var client: Client!
13
+    
14
+    public let url: URL
15
+    public let title: String
16
+    public let description: String
17
+    public let image: URL?
18
+    public let kind: Kind
19
+    public let authorName: String?
20
+    public let authorURL: URL?
21
+    public let providerName: String?
22
+    public let providerURL: URL?
23
+    public let html: String?
24
+    public let width: Int?
25
+    public let height: Int?
26
+    
27
+    private enum CodingKeys: String, CodingKey {
28
+        case url
29
+        case title
30
+        case description
31
+        case image
32
+        case kind = "type"
33
+        case authorName = "author_name"
34
+        case authorURL = "author_url"
35
+        case providerName = "provider_name"
36
+        case providerURL = "provider_url"
37
+        case html
38
+        case width
39
+        case height
40
+    }
41
+}
42
+
43
+extension Card {
44
+    public enum Kind: String, Decodable {
45
+        case link
46
+        case photo
47
+        case video
48
+        case rich
49
+    }
50
+}

+ 26
- 0
Pachyderm/Model/ConversationContext.swift View File

@@ -0,0 +1,26 @@
1
+//
2
+//  Context.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public class ConversationContext: Decodable, ClientModel {
12
+    var client: Client! {
13
+        didSet {
14
+            ancestors.client = client
15
+            descendants.client = client
16
+        }
17
+    }
18
+    
19
+    public private(set) var ancestors: [Status]
20
+    public private(set) var descendants: [Status]
21
+    
22
+    private enum CodingKeys: String, CodingKey {
23
+        case ancestors
24
+        case descendants
25
+    }
26
+}

+ 32
- 0
Pachyderm/Model/Emoji.swift View File

@@ -0,0 +1,32 @@
1
+//
2
+//  Emoji.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/8/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public class Emoji: Decodable, ClientModel {
12
+    var client: Client!
13
+    
14
+    let shortcode: String
15
+    let url: URL
16
+    let staticURL: URL
17
+    // TODO: missing in pleroma
18
+//    let visibleInPicker: Bool
19
+    
20
+    private enum CodingKeys: String, CodingKey {
21
+        case shortcode
22
+        case url
23
+        case staticURL = "static_url"
24
+//        case visibleInPicker = "visible_in_picker"
25
+    }
26
+}
27
+
28
+extension Emoji: CustomDebugStringConvertible {
29
+    public var debugDescription: String {
30
+        return ":\(shortcode):"
31
+    }
32
+}

+ 70
- 0
Pachyderm/Model/Filter.swift View File

@@ -0,0 +1,70 @@
1
+//
2
+//  Filter.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public class Filter: Decodable, ClientModel {
12
+    var client: Client!
13
+    
14
+    public let id: String
15
+    public var phrase: String
16
+    private var context: [String]
17
+    public var expiresAt: Date?
18
+    public var irreversible: Bool
19
+    public var wholeWord: Bool
20
+    
21
+    public var contexts: [Context] {
22
+        get {
23
+            return context.compactMap(Context.init)
24
+        }
25
+        set {
26
+            context = contexts.contextStrings
27
+        }
28
+    }
29
+    
30
+    public func update(completion: Client.Callback<Filter>?) {
31
+        let request = Request<Filter>(method: .put, path: "/api/v1/filters/\(id)", body: .parameters([
32
+            "phrase" => phrase,
33
+            "irreversible" => irreversible,
34
+            "whole_word" => wholeWord,
35
+            "expires_at" => expiresAt
36
+        ] + "context" => context))
37
+        client.run(request) { result in
38
+            completion?(result)
39
+        }
40
+    }
41
+    
42
+    public func delete(completion: @escaping Client.Callback<Empty>) {
43
+        let request = Request<Empty>(method: .delete, path: "/api/v1/filters/\(id)")
44
+        client.run(request, completion: completion)
45
+    }
46
+    
47
+    private enum CodingKeys: String, CodingKey {
48
+        case id
49
+        case phrase
50
+        case context
51
+        case expiresAt = "expires_at"
52
+        case irreversible
53
+        case wholeWord = "whole_word"
54
+    }
55
+}
56
+
57
+extension Filter {
58
+    public enum Context: String, Decodable {
59
+        case home
60
+        case notifications
61
+        case `public`
62
+        case thread
63
+    }
64
+}
65
+
66
+extension Array where Element == Filter.Context {
67
+    var contextStrings: [String] {
68
+        return map { $0.rawValue }
69
+    }
70
+}

+ 43
- 0
Pachyderm/Model/Hashtag.swift View File

@@ -0,0 +1,43 @@
1
+//
2
+//  Hashtag.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public class Hashtag: Decodable, ClientModel {
12
+    var client: Client!
13
+    
14
+    public let name: String
15
+    public let url: URL
16
+    public let history: [History]?
17
+    
18
+    public init(name: String, url: URL) {
19
+        self.name = name
20
+        self.url = url
21
+        self.history = nil
22
+    }
23
+    
24
+    private enum CodingKeys: String, CodingKey {
25
+        case name
26
+        case url
27
+        case history
28
+    }
29
+}
30
+
31
+extension Hashtag {
32
+    public class History: Decodable {
33
+        public let day: Date
34
+        public let uses: Int
35
+        public let accounts: Int
36
+        
37
+        private enum CodingKeys: String, CodingKey {
38
+            case day
39
+            case uses
40
+            case accounts
41
+        }
42
+    }
43
+}

+ 60
- 0
Pachyderm/Model/Instance.swift View File

@@ -0,0 +1,60 @@
1
+//
2
+//  Instance.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public class Instance: Decodable, ClientModel {
12
+    var client: Client! {
13
+        didSet {
14
+            contactAccount.client = client
15
+        }
16
+    }
17
+    
18
+    public let uri: String
19
+    public let title: String
20
+    public let description: String
21
+    public let email: String
22
+    public let version: String
23
+    public let urls: [String: URL]
24
+    public let languages: [String]
25
+    public let contactAccount: Account
26
+    
27
+    // MARK: Unofficial additions to the Mastodon API.
28
+    public let stats: Stats?
29
+    public let thumbnail: URL?
30
+    public let maxStatusCharacters: Int?
31
+    
32
+    private enum CodingKeys: String, CodingKey {
33
+        case uri
34
+        case title
35
+        case description
36
+        case email
37
+        case version
38
+        case urls
39
+        case languages
40
+        case contactAccount = "contact_account"
41
+        
42
+        case stats
43
+        case thumbnail
44
+        case maxStatusCharacters = "max_toot_chars"
45
+    }
46
+}
47
+
48
+extension Instance {
49
+    public class Stats: Decodable {
50
+        public let domainCount: Int?
51
+        public let statusCount: Int?
52
+        public let userCount: Int?
53
+        
54
+        private enum CodingKeys: String, CodingKey {
55
+            case domainCount = "domain_count"
56
+            case statusCount = "status_count"
57
+            case userCount = "user_count"
58
+        }
59
+    }
60
+}

+ 53
- 0
Pachyderm/Model/List.swift View File

@@ -0,0 +1,53 @@
1
+//
2
+//  List.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public class List: Decodable, ClientModel {
12
+    var client: Client!
13
+    
14
+    public let id: String
15
+    public var title: String
16
+    
17
+    public func getAccounts(range: RequestRange = .default, completion: @escaping Client.Callback<[Account]>) {
18
+        var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(id)/accounts")
19
+        request.range = range
20
+        client.run(request, completion: completion)
21
+    }
22
+    
23
+    public func update(completion: Client.Callback<List>?) {
24
+        let request = Request<List>(method: .put, path: "/api/v1/lists/\(id)", body: .parameters(["title" => title]))
25
+        client.run(request) { result in
26
+            completion?(result)
27
+        }
28
+    }
29
+    
30
+    public func delete(completion: @escaping Client.Callback<Empty>) {
31
+        let request = Request<Empty>(method: .delete, path: "/api/v1/lists/\(id)")
32
+        client.run(request, completion: completion)
33
+    }
34
+    
35
+    public func add(accounts: [Account], completion: @escaping Client.Callback<Empty>) {
36
+        let request = Request<Empty>(method: .post, path: "/api/v1/lists/\(id)/accounts", body: .parameters(
37
+                "account_ids" => accounts.map { $0.id }
38
+            ))
39
+        client.run(request, completion: completion)
40
+    }
41
+    
42
+    public func remove(accounts: [Account], completion: @escaping Client.Callback<Empty>) {
43
+        let request = Request<Empty>(method: .delete, path: "/api/v1/lists/\(id)/accounts", body: .parameters(
44
+                "account_ids" => accounts.map { $0.id }
45
+            ))
46
+        client.run(request, completion: completion)
47
+    }
48
+    
49
+    private enum CodingKeys: String, CodingKey {
50
+        case id
51
+        case title
52
+    }
53
+}

+ 23
- 0
Pachyderm/Model/LoginSettings.swift View File

@@ -0,0 +1,23 @@
1
+//
2
+//  LoginSettings.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public class LoginSettings: Decodable {
12
+    public let accessToken: String
13
+    private let scope: String
14
+    
15
+    public var scopes: [Scope] {
16
+        return scope.components(separatedBy: .whitespaces).compactMap(Scope.init)
17
+    }
18
+    
19
+    private enum CodingKeys: String, CodingKey {
20
+        case accessToken = "access_token"
21
+        case scope
22
+    }
23
+}

+ 17
- 0
Pachyderm/Model/MastodonError.swift View File

@@ -0,0 +1,17 @@
1
+//
2
+//  MastodonError.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/8/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+struct MastodonError: Decodable, CustomStringConvertible {
12
+    var description: String
13
+    
14
+    private enum CodingKeys: String, CodingKey {
15
+        case description = "error"
16
+    }
17
+}

+ 25
- 0
Pachyderm/Model/Mention.swift View File

@@ -0,0 +1,25 @@
1
+//
2
+//  Mention.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public class Mention: Decodable, ClientModel {
12
+    var client: Client!
13
+    
14
+    public let url: URL
15
+    public let username: String
16
+    public let acct: String
17
+    public let id: String
18
+    
19
+    private enum CodingKeys: String, CodingKey {
20
+        case url
21
+        case username
22
+        case acct
23
+        case id
24
+    }
25
+}

+ 48
- 0
Pachyderm/Model/Notification.swift View File

@@ -0,0 +1,48 @@
1
+//
2
+//  Notification.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public class Notification: Decodable, ClientModel {
12
+    var client: Client! {
13
+        didSet {
14
+            account.client = client
15
+            status?.client = client
16
+        }
17
+    }
18
+    
19
+    public let id: String
20
+    public let kind: Kind
21
+    public let createdAt: Date
22
+    public let account: Account
23
+    public let status: Status?
24
+    
25
+    public func dismiss(completion: @escaping Client.Callback<Empty>) {
26
+        let request = Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: .parameters([
27
+                "id" => id
28
+            ]))
29
+        client.run(request, completion: completion)
30
+    }
31
+    
32
+    private enum CodingKeys: String, CodingKey {
33
+        case id
34
+        case kind = "type"
35
+        case createdAt = "created_at"
36
+        case account
37
+        case status
38
+    }
39
+}
40
+
41
+extension Notification {
42
+    public enum Kind: String, Decodable {
43
+        case mention
44
+        case reblog
45
+        case favourite
46
+        case follow
47
+    }
48
+}

+ 26
- 0
Pachyderm/Model/PushSubscription.swift View File

@@ -0,0 +1,26 @@
1
+//
2
+//  PushSubscription.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public class PushSubscription: Decodable, ClientModel {
12
+    var client: Client!
13
+    
14
+    public let id: String
15
+    public let endpoint: URL
16
+    public let serverKey: String
17
+    //    TODO: WTF is this?
18
+//    public let alerts
19
+    
20
+    private enum CodingKeys: String, CodingKey {
21
+        case id
22
+        case endpoint
23
+        case serverKey = "server_key"
24
+//        case alerts
25
+    }
26
+}

+ 21
- 0
Pachyderm/Model/RegisteredApplication.swift View File

@@ -0,0 +1,21 @@
1
+//
2
+//  RegisteredApplication.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public class RegisteredApplication: Decodable {
12
+    public let id: String
13
+    public let clientID: String
14
+    public let clientSecret: String
15
+    
16
+    private enum CodingKeys: String, CodingKey {
17
+        case id
18
+        case clientID = "client_id"
19
+        case clientSecret = "client_secret"
20
+    }
21
+}

+ 35
- 0
Pachyderm/Model/Relationship.swift View File

@@ -0,0 +1,35 @@
1
+//
2
+//  Relationship.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public class Relationship: Decodable, ClientModel {
12
+    var client: Client!
13
+    
14
+    public let id: String
15
+    public let following: Bool
16
+    public let followedBy: Bool
17
+    public let blocked: Bool
18
+    public let muting: Bool
19
+    public let mutingNotifications: Bool
20
+    public let followRequested: Bool
21
+    public let domainBlocking: Bool
22
+    public let showingReblogs: Bool
23
+    
24
+    private enum CodingKeys: String, CodingKey {
25
+        case id
26
+        case following
27
+        case followedBy = "followed_by"
28
+        case blocked
29
+        case muting
30
+        case mutingNotifications = "muting_notifications"
31
+        case followRequested = "requested"
32
+        case domainBlocking = "domain_blocking"
33
+        case showingReblogs = "showing_reblogs"
34
+    }
35
+}

+ 21
- 0
Pachyderm/Model/Report.swift View File

@@ -0,0 +1,21 @@
1
+//
2
+//  Report.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public class Report: Decodable, ClientModel {
12
+    var client: Client!
13
+    
14
+    public let id: String
15
+    public let actionTaken: Bool
16
+    
17
+    private enum CodingKeys: String, CodingKey {
18
+        case id
19
+        case actionTaken = "action_taken"
20
+    }
21
+}

+ 21
- 0
Pachyderm/Model/Scope.swift View File

@@ -0,0 +1,21 @@
1
+//
2
+//  Scope.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public enum Scope: String {
12
+    case read
13
+    case write
14
+    case follow
15
+}
16
+
17
+extension Array where Element == Scope {
18
+    var scopeString: String {
19
+        return map { $0.rawValue }.joined(separator: " ")
20
+    }
21
+}

+ 28
- 0
Pachyderm/Model/SearchResults.swift View File

@@ -0,0 +1,28 @@
1
+//
2
+//  SearchResults.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public class SearchResults: Decodable, ClientModel {
12
+    var client: Client! {
13
+        didSet {
14
+            accounts.client = client
15
+            statuses.client = client
16
+        }
17
+    }
18
+    
19
+    public private(set) var accounts: [Account]
20
+    public private(set) var statuses: [Status]
21
+    public let hashtags: [String]
22
+    
23
+    private enum CodingKeys: String, CodingKey {
24
+        case accounts
25
+        case statuses
26
+        case hashtags
27
+    }
28
+}

+ 222
- 0
Pachyderm/Model/Status.swift View File

@@ -0,0 +1,222 @@
1
+//
2
+//  Status.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public class Status: Decodable, ClientModel {
12
+    var client: Client! {
13
+        didSet {
14
+            didSetClient()
15
+        }
16
+    }
17
+    // when reblog.client is set directly from self.client didSet, reblog.client didSet is never called
18
+    private func didSetClient() {
19
+        account.client = client
20
+        reblog?.client = client
21
+        emojis.client = client
22
+        attachments.client = client
23
+        mentions.client = client
24
+        hashtags.client = client
25
+        application?.client = client
26
+    }
27
+    
28
+    public let id: String
29
+    public let uri: String
30
+    public let url: URL?
31
+    public let account: Account
32
+    public let inReplyToID: String?
33
+    public let inReplyToAccountID: String?
34
+    public private(set) var reblog: Status?
35
+    public let content: String
36
+    public let createdAt: Date
37
+    public private(set) var emojis: [Emoji]
38
+    // TODO: missing from pleroma
39
+//    public let repliesCount: Int
40
+    public let reblogsCount: Int
41
+    public let favouritesCount: Int
42
+    public var reblogged: Bool?
43
+    public var favourited: Bool?
44
+    public var muted: Bool?
45
+    public let sensitive: Bool
46
+    public let spoilerText: String
47
+    public let visibility: Visibility
48
+    public private(set) var attachments: [Attachment]
49
+    public private(set) var mentions: [Mention]
50
+    public private(set) var hashtags: [Hashtag]
51
+    public private(set) var application: Application?
52
+    public let language: String?
53
+    public var pinned: Bool?
54
+    
55
+    public func getContext(completion: @escaping Client.Callback<ConversationContext>) {
56
+        let request = Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(id)/context")
57
+        client.run(request, completion: completion)
58
+    }
59
+    
60
+    public func getCard(completion: @escaping Client.Callback<Card>) {
61
+        let request = Request<Card>(method: .get, path: "/api/v1/statuses/\(id)/card")
62
+        client.run(request, completion: completion)
63
+    }
64
+    
65
+    public func getFavourites(range: RequestRange = .default, completion: @escaping Client.Callback<[Account]>) {
66
+        var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(id)/favourited_by")
67
+        request.range = range
68
+        client.run(request, completion: completion)
69
+    }
70
+    
71
+    public func getReblogs(range: RequestRange = .default, completion: @escaping Client.Callback<[Account]>) {
72
+        var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(id)/reblogged_by")
73
+        request.range = range
74
+        client.run(request, completion: completion)
75
+    }
76
+    
77
+    public func delete(completion: @escaping Client.Callback<Empty>) {
78
+        let request = Request<Empty>(method: .delete, path: "/api/v1/statuses/\(id)")
79
+        client.run(request, completion: completion)
80
+    }
81
+    
82
+    public func reblog(completion: @escaping Client.Callback<Status>) {
83
+        let oldValue = reblogged
84
+        let request = Request<Status>(method: .post, path: "/api/v1/statuses/\(id)/reblog")
85
+        client.run(request) { response in
86
+            if case .success = response {
87
+                self.reblogged = true
88
+            } else {
89
+                self.reblogged = oldValue
90
+            }
91
+            completion(response)
92
+        }
93
+    }
94
+    
95
+    public func unreblog(completion: @escaping Client.Callback<Status>) {
96
+        let oldValue = reblogged
97
+        let request = Request<Status>(method: .post, path: "/api/v1/statuses/\(id)/unreblog")
98
+        client.run(request) { response in
99
+            if case .success = response {
100
+                self.reblogged = false
101
+            } else {
102
+                self.reblogged = oldValue
103
+            }
104
+            completion(response)
105
+        }
106
+    }
107
+    
108
+    public func favourite(completion: @escaping Client.Callback<Status>) {
109
+        let oldValue = favourited
110
+        let request = Request<Status>(method: .post, path: "/api/v1/statuses/\(id)/favourite")
111
+        client.run(request) { response in
112
+            if case .success = response {
113
+                self.favourited = true
114
+            } else {
115
+                self.favourited = oldValue
116
+            }
117
+            completion(response)
118
+        }
119
+    }
120
+    
121
+    public func unfavourite(completion: @escaping Client.Callback<Status>) {
122
+        let oldValue = favourited
123
+        let request = Request<Status>(method: .post, path: "/api/v1/statuses/\(id)/unfavourite")
124
+        client.run(request) { response in
125
+            if case .success = response {
126
+                self.favourited = false
127
+            } else {
128
+                self.favourited = oldValue
129
+            }
130
+            completion(response)
131
+        }
132
+    }
133
+    
134
+    public func pin(completion: @escaping Client.Callback<Status>) {
135
+        let oldValue = pinned
136
+        let request = Request<Status>(method: .post, path: "/api/v1/statuses/\(id)/pin")
137
+        client.run(request) { response in
138
+            if case .success = response {
139
+                self.pinned = true
140
+            } else {
141
+                self.pinned = oldValue
142
+            }
143
+            completion(response)
144
+        }
145
+    }
146
+    
147
+    public func unpin(completion: @escaping Client.Callback<Status>) {
148
+        let oldValue = pinned
149
+        let request = Request<Status>(method: .post, path: "/api/v1/statuses/\(id)/unpin")
150
+        client.run(request) { response in
151
+            if case .success = response {
152
+                self.pinned = false
153
+            } else {
154
+                self.pinned = oldValue
155
+            }
156
+            completion(response)
157
+        }
158
+    }
159
+    
160
+    public func muteConversation(completion: @escaping Client.Callback<Status>) {
161
+        let oldValue = muted
162
+        let request = Request<Status>(method: .post, path: "/api/v1/statuses/\(id)/mute")
163
+        client.run(request) { response in
164
+            if case .success = response {
165
+                self.muted = true
166
+            } else {
167
+                self.muted = oldValue
168
+            }
169
+            completion(response)
170
+        }
171
+    }
172
+    
173
+    public func unmuteConversation(completion: @escaping Client.Callback<Status>) {
174
+        let oldValue = muted
175
+        let request = Request<Status>(method: .post, path: "/api/v1/statuses/\(id)/unmute")
176
+        client.run(request) { response in
177
+            if case .success = response {
178
+                self.muted = false
179
+            } else {
180
+                self.muted = oldValue
181
+            }
182
+            completion(response)
183
+        }
184
+    }
185
+    
186
+    private enum CodingKeys: String, CodingKey {
187
+        case id
188
+        case uri
189
+        case url
190
+        case account
191
+        case inReplyToID = "in_reply_to_id"
192
+        case inReplyToAccountID = "in_reply_to_account_id"
193
+        case reblog
194
+        case content
195
+        case createdAt = "created_at"
196
+        case emojis
197
+//        case repliesCount = "replies_count"
198
+        case reblogsCount = "reblogs_count"
199
+        case favouritesCount = "favourites_count"
200
+        case reblogged
201
+        case favourited
202
+        case muted
203
+        case sensitive
204
+        case spoilerText = "spoiler_text"
205
+        case visibility
206
+        case attachments = "media_attachments"
207
+        case mentions
208
+        case hashtags = "tags"
209
+        case application
210
+        case language
211
+        case pinned
212
+    }
213
+}
214
+
215
+extension Status {
216
+    public enum Visibility: String, Codable, CaseIterable {
217
+        case `public`
218
+        case unlisted
219
+        case `private`
220
+        case direct
221
+    }
222
+}

+ 40
- 0
Pachyderm/Model/Timeline.swift View File

@@ -0,0 +1,40 @@
1
+//
2
+//  Timeline.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public enum Timeline {
12
+    case home
13
+    case `public`(local: Bool)
14
+    case tag(hashtag: String)
15
+    case list(id: String)
16
+    case direct
17
+}
18
+
19
+extension Timeline {
20
+    func request(range: RequestRange) -> Request<[Status]> {
21
+        var request: Request<[Status]>
22
+        switch self {
23
+        case .home:
24
+            request = Request(method: .get, path: "/api/v1/timelines/home")
25
+        case let .public(local):
26
+            request = Request(method: .get, path: "/api/v1/timelines/public")
27
+            if local {
28
+                request.queryParameters.append("local" => true)
29
+            }
30
+        case let .tag(hashtag):
31
+            request = Request(method: .get, path: "/api/v1/timeliens/tag/\(hashtag)")
32
+        case let .list(id):
33
+            request = Request(method: .get, path: "/api/v1/timelines/list/\(id)")
34
+        case .direct:
35
+            request = Request(method: .get, path: "/api/v1/timelines/direct")
36
+        }
37
+        request.range = range
38
+        return request
39
+    }
40
+}

+ 19
- 0
Pachyderm/Pachyderm.h View File

@@ -0,0 +1,19 @@
1
+//
2
+//  Pachyderm.h
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/8/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+#import <UIKit/UIKit.h>
10
+
11
+//! Project version number for Pachyderm.
12
+FOUNDATION_EXPORT double PachydermVersionNumber;
13
+
14
+//! Project version string for Pachyderm.
15
+FOUNDATION_EXPORT const unsigned char PachydermVersionString[];
16
+
17
+// In this header, you should import all the public headers of your framework using statements like #import <Pachyderm/PublicHeader.h>
18
+
19
+

+ 63
- 0
Pachyderm/Request/Body.swift View File

@@ -0,0 +1,63 @@
1
+//
2
+//  Body.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/8/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+enum Body {
12
+    case parameters([Parameter]?)
13
+    case formData([Parameter]?, FormAttachment?)
14
+    case empty
15
+}
16
+
17
+extension Body {
18
+    private static let boundary: String = "PachydermBoundary"
19
+    
20
+    var data: Data? {
21
+        switch self {
22
+        case let .parameters(parameters):
23
+            return parameters?.urlEncoded.data(using: .utf8)
24
+        case let .formData(parameters, attachment):
25
+            var data = Data()
26
+            parameters?.forEach { param in
27
+                guard let value = param.value else { return }
28
+                data.append("--\(Body.boundary)\r\n")
29
+                data.append("Content-Disposition: form-data; name=\"\(param.name)\"\r\n\r\n")
30
+                data.append("\(value)\r\n")
31
+            }
32
+            if let attachment = attachment {
33
+                data.append("--\(Body.boundary)\r\n")
34
+                data.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(attachment.fileName)\"\r\n")
35
+                data.append("Content-Type: \(attachment.mimeType)\r\n\r\n")
36
+                data.append(attachment.data)
37
+                data.append("\r\n")
38
+            }
39
+            
40
+            data.append("--\(Body.boundary)--\r\n")
41
+            return data
42
+        case .empty:
43
+            return nil
44
+        }
45
+    }
46
+    
47
+    var mimeType: String? {
48
+        switch self {
49
+        case let .parameters(parameters):
50
+            if parameters == nil {
51
+                return nil
52
+            }
53
+            return "application/x-www-form-urlencoded; charset=utf-8"
54
+        case let .formData(parameters, attachment):
55
+            if parameters == nil && attachment == nil {
56
+                return nil
57
+            }
58
+            return "multipart/form-data; boundary=\(Body.boundary)"
59
+        case .empty:
60
+            return nil
61
+        }
62
+    }
63
+}

+ 35
- 0
Pachyderm/Request/FormAttachment.swift View File

@@ -0,0 +1,35 @@
1
+//
2
+//  Attachment.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/8/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public struct FormAttachment {
12
+    let mimeType: String
13
+    let data: Data
14
+    let fileName: String
15
+    
16
+    public init(mimeType: String, data: Data, fileName: String) {
17
+        self.mimeType = mimeType
18
+        self.data = data
19
+        self.fileName = fileName
20
+    }
21
+}
22
+
23
+extension FormAttachment {
24
+    public init(jepgData data: Data, fileName: String = "file.jpg") {
25
+        self.init(mimeType: "image/jpg", data: data, fileName: fileName)
26
+    }
27
+    
28
+    public init(pngData data: Data, fileName: String = "file.png") {
29
+        self.init(mimeType: "image/png", data: data, fileName: fileName)
30
+    }
31
+    
32
+    public init(gifData data: Data, fileName: String = "file.gif") {
33
+        self.init(mimeType: "image/gif", data: data, fileName: fileName)
34
+    }
35
+}

+ 30
- 0
Pachyderm/Request/Method.swift View File

@@ -0,0 +1,30 @@
1
+//
2
+//  Method.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/8/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+enum Method {
12
+    case get, post, put, patch, delete
13
+}
14
+
15
+extension Method {
16
+    var name: String {
17
+        switch self {
18
+        case .get:
19
+            return "GET"
20
+        case .post:
21
+            return "POST"
22
+        case .put:
23
+            return "PUT"
24
+        case .patch:
25
+            return "PATCH"
26
+        case .delete:
27
+            return "DELETE"
28
+        }
29
+    }
30
+}

+ 80
- 0
Pachyderm/Request/Parameter.swift View File

@@ -0,0 +1,80 @@
1
+//
2
+//  Parameter.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/8/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+struct Parameter {
12
+    let name: String
13
+    let value: String?
14
+}
15
+precedencegroup ParameterizationPrecedence {
16
+    associativity: left
17
+    higherThan: AdditionPrecedence
18
+}
19
+infix operator => : ParameterizationPrecedence
20
+
21
+extension String {
22
+    static func =>(name: String, value: String?) -> Parameter {
23
+        return Parameter(name: name, value: value)
24
+    }
25
+    
26
+    static func =>(name: String, value: Bool?) -> Parameter {
27
+        return Parameter(name: name, value: value?.description)
28
+    }
29
+    
30
+    static func =>(name: String, value: Int?) -> Parameter {
31
+        return Parameter(name: name, value: value?.description)
32
+    }
33
+    
34
+    static func =>(name: String, value: Date?) -> Parameter {
35
+        if let value = value {
36
+            let formatter = ISO8601DateFormatter()
37
+            formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
38
+            let string = formatter.string(from: value)
39
+            return Parameter(name: name, value: string)
40
+        } else {
41
+            return Parameter(name: name, value: nil)
42
+        }
43
+    }
44
+    
45
+    static func =>(name: String, focus: (Float, Float)?) -> Parameter {
46
+        guard let focus = focus else { return Parameter(name: name, value: nil) }
47
+        return Parameter(name: name, value: "\(focus.0),\(focus.1)")
48
+    }
49
+    
50
+    static func =>(name: String, values: [String]?) -> [Parameter] {
51
+        guard let values = values else { return [] }
52
+        let name = "\(name)[]"
53
+        return values.map { Parameter(name: name, value: $0) }
54
+    }
55
+}
56
+
57
+extension Parameter: CustomStringConvertible {
58
+    var description: String {
59
+        if let value = value {
60
+            return "\(name)=\(value)"
61
+        } else {
62
+            return name
63
+        }
64
+    }
65
+}
66
+
67
+extension Array where Element == Parameter {
68
+    var urlEncoded: String {
69
+        return compactMap {
70
+            guard let value = $0.value else { return nil }
71
+            return "\($0.name)=\(value)"
72
+        }.joined(separator: "&")
73
+    }
74
+    
75
+    var queryItems: [URLQueryItem] {
76
+        return compactMap {
77
+            URLQueryItem(name: $0.name, value: $0.value)
78
+        }
79
+    }
80
+}

+ 57
- 0
Pachyderm/Request/Request.swift View File

@@ -0,0 +1,57 @@
1
+//
2
+//  Request.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/8/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+struct Request<ResultType: Decodable> {
12
+    let method: Method
13
+    let path: String
14
+    let body: Body
15
+    var queryParameters: [Parameter]
16
+    
17
+    init(method: Method, path: String, body: Body = .empty, queryParameters: [Parameter] = []) {
18
+        self.method = method
19
+        self.path = path
20
+        self.body = body
21
+        self.queryParameters = queryParameters
22
+    }
23
+}
24
+
25
+extension Request {
26
+    var range: RequestRange {
27
+        get {
28
+            let max = queryParameters.first { $0.name == "max_id" }
29
+            let since = queryParameters.first { $0.name == "since_id" }
30
+            let count = queryParameters.first { $0.name == "count" }
31
+            if let max = max, let count = count {
32
+                return .before(id: max.value!, count: Int(count.value!)!)
33
+            } else if let since = since, let count = count {
34
+                return .after(id: since.value!, count: Int(count.value!)!)
35
+            } else if let count = count {
36
+                return .count(Int(count.value!)!)
37
+            } else {
38
+                return .default
39
+            }
40
+        }
41
+        set {
42
+            let rangeParams = newValue.queryParameters
43
+            let max = rangeParams.first { $0.name == "max_id" }
44
+            let since = rangeParams.first { $0.name == "since_id" }
45
+            let count = rangeParams.first { $0.name == "count" }
46
+            if let max = max, let i = queryParameters.firstIndex(where: { $0.name == "max_id" }) {
47
+                queryParameters[i] = max
48
+            }
49
+            if let since = since, let i = queryParameters.firstIndex(where: { $0.name == "since_id" }) {
50
+                queryParameters[i] = since
51
+            }
52
+            if let count = count, let i = queryParameters.firstIndex(where: { $0.name == "count" }) {
53
+                queryParameters[i] = count
54
+            }
55
+        }
56
+    }
57
+}

+ 31
- 0
Pachyderm/Request/RequestRange.swift View File

@@ -0,0 +1,31 @@
1
+//
2
+//  RequestRange.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public enum RequestRange {
12
+    case `default`
13
+    case count(Int)
14
+    case before(id: String, count: Int?)
15
+    case after(id: String, count: Int?)
16
+}
17
+
18
+extension RequestRange {
19
+    var queryParameters: [Parameter] {
20
+        switch self {
21
+        case .default:
22
+            return []
23
+        case let .count(count):
24
+            return ["limit" => count]
25
+        case let .before(id, count):
26
+            return ["max_id" => id, "count" => count]
27
+        case let .after(id, count):
28
+            return ["since_id" => id, "count" => count]
29
+        }
30
+    }
31
+}

+ 13
- 0
Pachyderm/Response/Empty.swift View File

@@ -0,0 +1,13 @@
1
+//
2
+//  Empty.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public struct Empty: Decodable {
12
+    
13
+}

+ 73
- 0
Pachyderm/Response/Pagination.swift View File

@@ -0,0 +1,73 @@
1
+//
2
+//  Pagination.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/9/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public struct Pagination {
12
+    public let older: RequestRange?
13
+    public let newer: RequestRange?
14
+}
15
+
16
+extension Pagination {
17
+    init(string: String) {
18
+        let links = string.components(separatedBy: ",").compactMap(Item.init)
19
+        self.older = links.first(where: { $0.kind == .next })?.range
20
+        self.newer = links.first(where: { $0.kind == .prev })?.range
21
+    }
22
+}
23
+
24
+extension Pagination {
25
+    struct Item {
26
+        let kind: Kind
27
+        let id: String
28
+        let limit: Int?
29
+        
30
+        var range: RequestRange {
31
+            switch kind {
32
+            case .next:
33
+                return .after(id: id, count: limit)
34
+            case .prev:
35
+                return .before(id: id, count: limit)
36
+            }
37
+        }
38
+        
39
+        init?(string: String) {
40
+            let segments = string.components(separatedBy: .whitespaces).filter { !$0.isEmpty }.joined().components(separatedBy: ";")
41
+            
42
+            let url = segments.first.flatMap { str in
43
+                String(str[str.index(after: str.startIndex)..<str.index(before: str.endIndex)])
44
+            }
45
+            let rel = segments.last?.replacingOccurrences(of: "\"", with: "").trimmingCharacters(in: .whitespaces).components(separatedBy: "=")
46
+            
47
+            guard let validURL = url,
48
+                let key = rel?.first,
49
+                key == "rel",
50
+                let value = rel?.last,
51
+                let kind = Kind(rawValue: value),
52
+                let components = URLComponents(string: validURL),
53
+                let queryItems = components.queryItems else { return nil }
54
+            
55
+            let since = queryItems.first { $0.name == "since_id" }?.value
56
+            let max = queryItems.first { $0.name == "max_id" }?.value
57
+            
58
+            guard let id = since ?? max else { return nil }
59
+            
60
+            let limit = queryItems.first { $0.name == "limit" }.flatMap { $0.value }.flatMap { Int($0) }
61
+            
62
+            self.kind = kind
63
+            self.id = id
64
+            self.limit = limit
65
+        }
66
+    }
67
+}
68
+
69
+extension Pagination.Item {
70
+    enum Kind: String {
71
+        case next, prev
72
+    }
73
+}

+ 14
- 0
Pachyderm/Response/Response.swift View File

@@ -0,0 +1,14 @@
1
+//
2
+//  Response.swift
3
+//  Pachyderm
4
+//
5
+//  Created by Shadowfacts on 9/8/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import Foundation
10
+
11
+public enum Response<Result: Decodable> {
12
+    case success(Result, Pagination?)
13
+    case failure(Error)
14
+}

+ 22
- 0
PachydermTests/Info.plist View File

@@ -0,0 +1,22 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+<plist version="1.0">
4
+<dict>
5
+	<key>CFBundleDevelopmentRegion</key>
6
+	<string>$(DEVELOPMENT_LANGUAGE)</string>
7
+	<key>CFBundleExecutable</key>
8
+	<string>$(EXECUTABLE_NAME)</string>
9
+	<key>CFBundleIdentifier</key>
10
+	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
11
+	<key>CFBundleInfoDictionaryVersion</key>
12
+	<string>6.0</string>
13
+	<key>CFBundleName</key>
14
+	<string>$(PRODUCT_NAME)</string>
15
+	<key>CFBundlePackageType</key>
16
+	<string>BNDL</string>
17
+	<key>CFBundleShortVersionString</key>
18
+	<string>1.0</string>
19
+	<key>CFBundleVersion</key>
20
+	<string>1</string>
21
+</dict>
22
+</plist>

+ 34
- 0
PachydermTests/PachydermTests.swift View File

@@ -0,0 +1,34 @@
1
+//
2
+//  PachydermTests.swift
3
+//  PachydermTests
4
+//
5
+//  Created by Shadowfacts on 9/8/18.
6
+//  Copyright © 2018 Shadowfacts. All rights reserved.
7
+//
8
+
9
+import XCTest
10
+@testable import Pachyderm
11
+
12
+class PachydermTests: XCTestCase {
13
+
14
+    override func setUp() {
15
+        // Put setup code here. This method is called before the invocation of each test method in the class.
16
+    }
17
+
18
+    override func tearDown() {
19
+        // Put teardown code here. This method is called after the invocation of each test method in the class.
20
+    }
21
+
22
+    func testExample() {
23
+        // This is an example of a functional test case.
24
+        // Use XCTAssert and related functions to verify your tests produce the correct results.
25
+    }
26
+
27
+    func testPerformanceExample() {
28
+        // This is an example of a performance test case.
29
+        self.measure {
30
+            // Put the code you want to measure the time of here.
31
+        }
32
+    }
33
+
34
+}

+ 471
- 10
Tusker.xcodeproj/project.pbxproj View File

@@ -7,9 +7,47 @@
7 7
 	objects = {
8 8
 
9 9
 /* Begin PBXBuildFile section */
10
-		04DACE8A212CA6B7009840C4 /* Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE89212CA6B7009840C4 /* Timeline.swift */; };
11 10
 		04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; };
12 11
 		04DACE8E212CC7CC009840C4 /* AvatarCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* AvatarCache.swift */; };
12
+		D61099B42144B0CC00432DC2 /* Pachyderm.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61099AB2144B0CC00432DC2 /* Pachyderm.framework */; };
13
+		D61099BB2144B0CC00432DC2 /* PachydermTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099BA2144B0CC00432DC2 /* PachydermTests.swift */; };
14
+		D61099BD2144B0CC00432DC2 /* Pachyderm.h in Headers */ = {isa = PBXBuildFile; fileRef = D61099AD2144B0CC00432DC2 /* Pachyderm.h */; settings = {ATTRIBUTES = (Public, ); }; };
15
+		D61099C02144B0CC00432DC2 /* Pachyderm.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61099AB2144B0CC00432DC2 /* Pachyderm.framework */; };
16
+		D61099C12144B0CC00432DC2 /* Pachyderm.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D61099AB2144B0CC00432DC2 /* Pachyderm.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
17
+		D61099C92144B13C00432DC2 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099C82144B13C00432DC2 /* Client.swift */; };
18
+		D61099CB2144B20500432DC2 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099CA2144B20500432DC2 /* Request.swift */; };
19
+		D61099D02144B2D700432DC2 /* Method.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099CF2144B2D700432DC2 /* Method.swift */; };
20
+		D61099D22144B2E600432DC2 /* Body.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099D12144B2E600432DC2 /* Body.swift */; };
21
+		D61099D42144B32E00432DC2 /* Parameter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099D32144B32E00432DC2 /* Parameter.swift */; };
22
+		D61099D62144B4B200432DC2 /* FormAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099D52144B4B200432DC2 /* FormAttachment.swift */; };
23
+		D61099D92144B76400432DC2 /* Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099D82144B76400432DC2 /* Data.swift */; };
24
+		D61099DC2144BDBF00432DC2 /* Response.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099DB2144BDBF00432DC2 /* Response.swift */; };
25
+		D61099DF2144C11400432DC2 /* MastodonError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099DE2144C11400432DC2 /* MastodonError.swift */; };
26
+		D61099E12144C1DC00432DC2 /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099E02144C1DC00432DC2 /* Account.swift */; };
27
+		D61099E32144C38900432DC2 /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099E22144C38900432DC2 /* Emoji.swift */; };
28
+		D61099E5214561AB00432DC2 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099E4214561AB00432DC2 /* Application.swift */; };
29
+		D61099E7214561FF00432DC2 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099E6214561FF00432DC2 /* Attachment.swift */; };
30
+		D61099E92145658300432DC2 /* Card.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099E82145658300432DC2 /* Card.swift */; };
31
+		D61099EB2145661700432DC2 /* ConversationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099EA2145661700432DC2 /* ConversationContext.swift */; };
32
+		D61099ED2145664800432DC2 /* Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099EC2145664800432DC2 /* Filter.swift */; };
33
+		D61099EF214566C000432DC2 /* Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099EE214566C000432DC2 /* Instance.swift */; };
34
+		D61099F12145686D00432DC2 /* List.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099F02145686D00432DC2 /* List.swift */; };
35
+		D61099F32145688600432DC2 /* Mention.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099F22145688600432DC2 /* Mention.swift */; };
36
+		D61099F5214568C300432DC2 /* Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099F4214568C300432DC2 /* Notification.swift */; };
37
+		D61099F72145693500432DC2 /* PushSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099F62145693500432DC2 /* PushSubscription.swift */; };
38
+		D61099F92145698900432DC2 /* Relationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099F82145698900432DC2 /* Relationship.swift */; };
39
+		D61099FB214569F600432DC2 /* Report.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099FA214569F600432DC2 /* Report.swift */; };
40
+		D61099FD21456A1D00432DC2 /* SearchResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099FC21456A1D00432DC2 /* SearchResults.swift */; };
41
+		D61099FF21456A4C00432DC2 /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61099FE21456A4C00432DC2 /* Status.swift */; };
42
+		D6109A0121456B0800432DC2 /* Hashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6109A0021456B0800432DC2 /* Hashtag.swift */; };
43
+		D6109A032145722C00432DC2 /* RegisteredApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6109A022145722C00432DC2 /* RegisteredApplication.swift */; };
44
+		D6109A05214572BF00432DC2 /* Scope.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6109A04214572BF00432DC2 /* Scope.swift */; };
45
+		D6109A072145756700432DC2 /* LoginSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6109A062145756700432DC2 /* LoginSettings.swift */; };
46
+		D6109A0921458C4A00432DC2 /* Empty.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6109A0821458C4A00432DC2 /* Empty.swift */; };
47
+		D6109A0B2145953C00432DC2 /* ClientModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6109A0A2145953C00432DC2 /* ClientModel.swift */; };
48
+		D6109A0D214599E100432DC2 /* RequestRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6109A0C214599E100432DC2 /* RequestRange.swift */; };
49
+		D6109A0F21459B6900432DC2 /* Pagination.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6109A0E21459B6900432DC2 /* Pagination.swift */; };
50
+		D6109A11214607D500432DC2 /* Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6109A10214607D500432DC2 /* Timeline.swift */; };
13 51
 		D6333B372137838300CE884A /* AttributedString+Trim.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Trim.swift */; };
14 52
 		D6333B772138D94E00CE884A /* ComposeMediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B762138D94E00CE884A /* ComposeMediaView.swift */; };
15 53
 		D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; };
@@ -28,6 +66,7 @@
28 66
 		D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AAC2128D88B005A6F37 /* LocalData.swift */; };
29 67
 		D64D0AAF2128D954005A6F37 /* Onboarding.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D64D0AAE2128D954005A6F37 /* Onboarding.storyboard */; };
30 68
 		D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
69
+		D65A37F321472F300087646E /* SwiftSoup.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */; };
31 70
 		D663625D2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D663625C2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib */; };
32 71
 		D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663625E2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift */; };
33 72
 		D663626221360B1900C9CBA2 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626121360B1900C9CBA2 /* Preferences.swift */; };
@@ -50,7 +89,6 @@
50 89
 		D667E5F32135BC260057A976 /* Conversation.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D667E5F22135BC260057A976 /* Conversation.storyboard */; };
51 90
 		D667E5F52135BCD50057A976 /* ConversationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F42135BCD50057A976 /* ConversationViewController.swift */; };
52 91
 		D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */; };
53
-		D6BED16F212663DA00F02DA0 /* SwiftSoup.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */; };
54 92
 		D6BED170212663DA00F02DA0 /* SwiftSoup.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
55 93
 		D6BED174212667E900F02DA0 /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* StatusTableViewCell.swift */; };
56 94
 		D6C94D852139DFD800CB5196 /* LargeImage.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6C94D842139DFD800CB5196 /* LargeImage.storyboard */; };
@@ -62,14 +100,33 @@
62 100
 		D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */; };
63 101
 		D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */; };
64 102
 		D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; };
65
-		D6F953E7212519A400CF0F2B /* MastodonKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6F953E6212519A400CF0F2B /* MastodonKit.framework */; };
66
-		D6F953E8212519A400CF0F2B /* MastodonKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D6F953E6212519A400CF0F2B /* MastodonKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
67 103
 		D6F953EC212519E700CF0F2B /* TimelineTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F953EB212519E700CF0F2B /* TimelineTableViewController.swift */; };
68 104
 		D6F953EE21251A0700CF0F2B /* Timeline.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6F953ED21251A0700CF0F2B /* Timeline.storyboard */; };
69 105
 		D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F953EF21251A2900CF0F2B /* MastodonController.swift */; };
70 106
 /* End PBXBuildFile section */
71 107
 
72 108
 /* Begin PBXContainerItemProxy section */
109
+		D61099B52144B0CC00432DC2 /* PBXContainerItemProxy */ = {
110
+			isa = PBXContainerItemProxy;
111
+			containerPortal = D6D4DDC4212518A000E1C4BB /* Project object */;
112
+			proxyType = 1;
113
+			remoteGlobalIDString = D61099AA2144B0CC00432DC2;
114
+			remoteInfo = Pachyderm;
115
+		};
116
+		D61099B72144B0CC00432DC2 /* PBXContainerItemProxy */ = {
117
+			isa = PBXContainerItemProxy;
118
+			containerPortal = D6D4DDC4212518A000E1C4BB /* Project object */;
119
+			proxyType = 1;
120
+			remoteGlobalIDString = D6D4DDCB212518A000E1C4BB;
121
+			remoteInfo = Tusker;
122
+		};
123
+		D61099BE2144B0CC00432DC2 /* PBXContainerItemProxy */ = {
124
+			isa = PBXContainerItemProxy;
125
+			containerPortal = D6D4DDC4212518A000E1C4BB /* Project object */;
126
+			proxyType = 1;
127
+			remoteGlobalIDString = D61099AA2144B0CC00432DC2;
128
+			remoteInfo = Pachyderm;
129
+		};
73 130
 		D6D4DDE1212518A200E1C4BB /* PBXContainerItemProxy */ = {
74 131
 			isa = PBXContainerItemProxy;
75 132
 			containerPortal = D6D4DDC4212518A000E1C4BB /* Project object */;
@@ -93,7 +150,7 @@
93 150
 			dstPath = "";
94 151
 			dstSubfolderSpec = 10;
95 152
 			files = (
96
-				D6F953E8212519A400CF0F2B /* MastodonKit.framework in Embed Frameworks */,
153
+				D61099C12144B0CC00432DC2 /* Pachyderm.framework in Embed Frameworks */,
97 154
 				D6BED170212663DA00F02DA0 /* SwiftSoup.framework in Embed Frameworks */,
98 155
 			);
99 156
 			name = "Embed Frameworks";
@@ -102,9 +159,48 @@
102 159
 /* End PBXCopyFilesBuildPhase section */
103 160
 
104 161
 /* Begin PBXFileReference section */
105
-		04DACE89212CA6B7009840C4 /* Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timeline.swift; sourceTree = "<group>"; };
106 162
 		04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarViewController.swift; sourceTree = "<group>"; };
107 163
 		04DACE8D212CC7CC009840C4 /* AvatarCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCache.swift; sourceTree = "<group>"; };
164
+		D61099AB2144B0CC00432DC2 /* Pachyderm.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pachyderm.framework; sourceTree = BUILT_PRODUCTS_DIR; };
165
+		D61099AD2144B0CC00432DC2 /* Pachyderm.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Pachyderm.h; sourceTree = "<group>"; };
166
+		D61099AE2144B0CC00432DC2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
167
+		D61099B32144B0CC00432DC2 /* PachydermTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PachydermTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
168
+		D61099BA2144B0CC00432DC2 /* PachydermTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PachydermTests.swift; sourceTree = "<group>"; };
169
+		D61099BC2144B0CC00432DC2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
170
+		D61099C82144B13C00432DC2 /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = "<group>"; };
171
+		D61099CA2144B20500432DC2 /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = "<group>"; };
172
+		D61099CF2144B2D700432DC2 /* Method.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Method.swift; sourceTree = "<group>"; };
173
+		D61099D12144B2E600432DC2 /* Body.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Body.swift; sourceTree = "<group>"; };
174
+		D61099D32144B32E00432DC2 /* Parameter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Parameter.swift; sourceTree = "<group>"; };
175
+		D61099D52144B4B200432DC2 /* FormAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormAttachment.swift; sourceTree = "<group>"; };
176
+		D61099D82144B76400432DC2 /* Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data.swift; sourceTree = "<group>"; };
177
+		D61099DB2144BDBF00432DC2 /* Response.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Response.swift; sourceTree = "<group>"; };
178
+		D61099DE2144C11400432DC2 /* MastodonError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonError.swift; sourceTree = "<group>"; };
179
+		D61099E02144C1DC00432DC2 /* Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Account.swift; sourceTree = "<group>"; };
180
+		D61099E22144C38900432DC2 /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = "<group>"; };
181
+		D61099E4214561AB00432DC2 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
182
+		D61099E6214561FF00432DC2 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; };
183
+		D61099E82145658300432DC2 /* Card.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Card.swift; sourceTree = "<group>"; };
184
+		D61099EA2145661700432DC2 /* ConversationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationContext.swift; sourceTree = "<group>"; };
185
+		D61099EC2145664800432DC2 /* Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Filter.swift; sourceTree = "<group>"; };
186
+		D61099EE214566C000432DC2 /* Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Instance.swift; sourceTree = "<group>"; };
187
+		D61099F02145686D00432DC2 /* List.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = List.swift; sourceTree = "<group>"; };
188
+		D61099F22145688600432DC2 /* Mention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mention.swift; sourceTree = "<group>"; };
189
+		D61099F4214568C300432DC2 /* Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notification.swift; sourceTree = "<group>"; };
190
+		D61099F62145693500432DC2 /* PushSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSubscription.swift; sourceTree = "<group>"; };
191
+		D61099F82145698900432DC2 /* Relationship.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Relationship.swift; sourceTree = "<group>"; };
192
+		D61099FA214569F600432DC2 /* Report.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Report.swift; sourceTree = "<group>"; };
193
+		D61099FC21456A1D00432DC2 /* SearchResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResults.swift; sourceTree = "<group>"; };
194
+		D61099FE21456A4C00432DC2 /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = "<group>"; };
195
+		D6109A0021456B0800432DC2 /* Hashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hashtag.swift; sourceTree = "<group>"; };
196
+		D6109A022145722C00432DC2 /* RegisteredApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisteredApplication.swift; sourceTree = "<group>"; };
197
+		D6109A04214572BF00432DC2 /* Scope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Scope.swift; sourceTree = "<group>"; };
198
+		D6109A062145756700432DC2 /* LoginSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginSettings.swift; sourceTree = "<group>"; };
199
+		D6109A0821458C4A00432DC2 /* Empty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Empty.swift; sourceTree = "<group>"; };
200
+		D6109A0A2145953C00432DC2 /* ClientModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientModel.swift; sourceTree = "<group>"; };
201
+		D6109A0C214599E100432DC2 /* RequestRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestRange.swift; sourceTree = "<group>"; };
202
+		D6109A0E21459B6900432DC2 /* Pagination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pagination.swift; sourceTree = "<group>"; };
203
+		D6109A10214607D500432DC2 /* Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timeline.swift; sourceTree = "<group>"; };
108 204
 		D6333B362137838300CE884A /* AttributedString+Trim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Trim.swift"; sourceTree = "<group>"; };
109 205
 		D6333B762138D94E00CE884A /* ComposeMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeMediaView.swift; sourceTree = "<group>"; };
110 206
 		D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = "<group>"; };
@@ -169,12 +265,27 @@
169 265
 /* End PBXFileReference section */
170 266
 
171 267
 /* Begin PBXFrameworksBuildPhase section */
268
+		D61099A82144B0CC00432DC2 /* Frameworks */ = {
269
+			isa = PBXFrameworksBuildPhase;
270
+			buildActionMask = 2147483647;
271
+			files = (
272
+			);
273
+			runOnlyForDeploymentPostprocessing = 0;
274
+		};
275
+		D61099B02144B0CC00432DC2 /* Frameworks */ = {
276
+			isa = PBXFrameworksBuildPhase;
277
+			buildActionMask = 2147483647;
278
+			files = (
279
+				D61099B42144B0CC00432DC2 /* Pachyderm.framework in Frameworks */,
280
+			);
281
+			runOnlyForDeploymentPostprocessing = 0;
282
+		};
172 283
 		D6D4DDC9212518A000E1C4BB /* Frameworks */ = {
173 284
 			isa = PBXFrameworksBuildPhase;
174 285
 			buildActionMask = 2147483647;
175 286
 			files = (
176
-				D6F953E7212519A400CF0F2B /* MastodonKit.framework in Frameworks */,
177
-				D6BED16F212663DA00F02DA0 /* SwiftSoup.framework in Frameworks */,
287
+				D61099C02144B0CC00432DC2 /* Pachyderm.framework in Frameworks */,
288
+				D65A37F321472F300087646E /* SwiftSoup.framework in Frameworks */,
178 289
 			);
179 290
 			runOnlyForDeploymentPostprocessing = 0;
180 291
 		};
@@ -195,6 +306,90 @@
195 306
 /* End PBXFrameworksBuildPhase section */
196 307
 
197 308
 /* Begin PBXGroup section */
309
+		D61099AC2144B0CC00432DC2 /* Pachyderm */ = {
310
+			isa = PBXGroup;
311
+			children = (
312
+				D61099AD2144B0CC00432DC2 /* Pachyderm.h */,
313
+				D61099AE2144B0CC00432DC2 /* Info.plist */,
314
+				D61099C82144B13C00432DC2 /* Client.swift */,
315
+				D6109A0A2145953C00432DC2 /* ClientModel.swift */,
316
+				D61099D72144B74500432DC2 /* Extensions */,
317
+				D61099CC2144B2C300432DC2 /* Request */,
318
+				D61099DA2144BDB600432DC2 /* Response */,
319
+				D61099DD2144C10C00432DC2 /* Model */,
320
+			);
321
+			path = Pachyderm;
322
+			sourceTree = "<group>";
323
+		};
324
+		D61099B92144B0CC00432DC2 /* PachydermTests */ = {
325
+			isa = PBXGroup;
326
+			children = (
327
+				D61099BA2144B0CC00432DC2 /* PachydermTests.swift */,
328
+				D61099BC2144B0CC00432DC2 /* Info.plist */,
329
+			);
330
+			path = PachydermTests;
331
+			sourceTree = "<group>";
332
+		};
333
+		D61099CC2144B2C300432DC2 /* Request */ = {
334
+			isa = PBXGroup;
335
+			children = (
336
+				D61099CA2144B20500432DC2 /* Request.swift */,
337
+				D6109A0C214599E100432DC2 /* RequestRange.swift */,
338
+				D61099CF2144B2D700432DC2 /* Method.swift */,
339
+				D61099D12144B2E600432DC2 /* Body.swift */,
340
+				D61099D32144B32E00432DC2 /* Parameter.swift */,
341
+				D61099D52144B4B200432DC2 /* FormAttachment.swift */,
342
+			);
343
+			path = Request;
344
+			sourceTree = "<group>";
345
+		};
346
+		D61099D72144B74500432DC2 /* Extensions */ = {
347
+			isa = PBXGroup;
348
+			children = (
349
+				D61099D82144B76400432DC2 /* Data.swift */,
350
+			);
351
+			path = Extensions;
352
+			sourceTree = "<group>";
353
+		};
354
+		D61099DA2144BDB600432DC2 /* Response */ = {
355
+			isa = PBXGroup;
356
+			children = (
357
+				D61099DB2144BDBF00432DC2 /* Response.swift */,
358
+				D6109A0821458C4A00432DC2 /* Empty.swift */,
359
+				D6109A0E21459B6900432DC2 /* Pagination.swift */,
360
+			);
361
+			path = Response;
362
+			sourceTree = "<group>";
363
+		};
364
+		D61099DD2144C10C00432DC2 /* Model */ = {
365
+			isa = PBXGroup;
366
+			children = (
367
+				D61099DE2144C11400432DC2 /* MastodonError.swift */,
368
+				D6109A04214572BF00432DC2 /* Scope.swift */,
369
+				D61099E02144C1DC00432DC2 /* Account.swift */,
370
+				D61099E4214561AB00432DC2 /* Application.swift */,
371
+				D61099E6214561FF00432DC2 /* Attachment.swift */,
372
+				D61099E82145658300432DC2 /* Card.swift */,
373
+				D61099EA2145661700432DC2 /* ConversationContext.swift */,
374
+				D61099E22144C38900432DC2 /* Emoji.swift */,
375
+				D61099EC2145664800432DC2 /* Filter.swift */,
376
+				D6109A0021456B0800432DC2 /* Hashtag.swift */,
377
+				D61099EE214566C000432DC2 /* Instance.swift */,
378
+				D61099F02145686D00432DC2 /* List.swift */,
379
+				D6109A062145756700432DC2 /* LoginSettings.swift */,
380
+				D61099F22145688600432DC2 /* Mention.swift */,
381
+				D61099F4214568C300432DC2 /* Notification.swift */,
382
+				D61099F62145693500432DC2 /* PushSubscription.swift */,
383
+				D6109A022145722C00432DC2 /* RegisteredApplication.swift */,
384
+				D61099F82145698900432DC2 /* Relationship.swift */,
385
+				D61099FA214569F600432DC2 /* Report.swift */,
386
+				D61099FC21456A1D00432DC2 /* SearchResults.swift */,
387
+				D61099FE21456A4C00432DC2 /* Status.swift */,
388
+				D6109A10214607D500432DC2 /* Timeline.swift */,
389
+			);
390
+			path = Model;
391
+			sourceTree = "<group>";
392
+		};
198 393
 		D641C780213DD7C4004B4513 /* Screens */ = {
199 394
 			isa = PBXGroup;
200 395
 			children = (
@@ -335,6 +530,13 @@
335 530
 			path = Transitions;
336 531
 			sourceTree = "<group>";
337 532
 		};
533
+		D65A37F221472F300087646E /* Frameworks */ = {
534
+			isa = PBXGroup;
535
+			children = (
536
+			);
537
+			name = Frameworks;
538
+			sourceTree = "<group>";
539
+		};
338 540
 		D663626021360A9600C9CBA2 /* Preferences */ = {
339 541
 			isa = PBXGroup;
340 542
 			children = (
@@ -379,10 +581,13 @@
379 581
 			children = (
380 582
 				D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */,
381 583
 				D6F953E6212519A400CF0F2B /* MastodonKit.framework */,
584
+				D61099AC2144B0CC00432DC2 /* Pachyderm */,
585
+				D61099B92144B0CC00432DC2 /* PachydermTests */,
382 586
 				D6D4DDCE212518A000E1C4BB /* Tusker */,
383 587
 				D6D4DDE3212518A200E1C4BB /* TuskerTests */,
384 588
 				D6D4DDEE212518A200E1C4BB /* TuskerUITests */,
385 589
 				D6D4DDCD212518A000E1C4BB /* Products */,
590
+				D65A37F221472F300087646E /* Frameworks */,
386 591
 			);
387 592
 			sourceTree = "<group>";
388 593
 		};
@@ -392,6 +597,8 @@
392 597
 				D6D4DDCC212518A000E1C4BB /* Tusker.app */,
393 598
 				D6D4DDE0212518A200E1C4BB /* TuskerTests.xctest */,
394 599
 				D6D4DDEB212518A200E1C4BB /* TuskerUITests.xctest */,
600
+				D61099AB2144B0CC00432DC2 /* Pachyderm.framework */,
601
+				D61099B32144B0CC00432DC2 /* PachydermTests.xctest */,
395 602
 			);
396 603
 			name = Products;
397 604
 			sourceTree = "<group>";
@@ -400,7 +607,6 @@