Compare commits
257 Commits
simple-swi
...
develop
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 14e8c11f02 | |
Shadowfacts | 9d9ea565f1 | |
Shadowfacts | 99db842411 | |
Shadowfacts | 184fe49c0f | |
Shadowfacts | 4719342a06 | |
Shadowfacts | 6df5f7fb08 | |
Shadowfacts | 02135aa0de | |
Shadowfacts | be5a4c03a6 | |
Shadowfacts | 2c1ba7926e | |
Shadowfacts | 911e66a159 | |
Shadowfacts | ab4bcfa50f | |
Shadowfacts | b94bfca406 | |
Shadowfacts | 7999ecafd0 | |
Shadowfacts | 1c6e464a4c | |
Shadowfacts | acd01a81cc | |
Shadowfacts | 8ac3deb55a | |
Shadowfacts | 5e9cc430c6 | |
Shadowfacts | 0b6ef6517b | |
Shadowfacts | 34a01094f7 | |
Shadowfacts | 95b215c6b5 | |
Shadowfacts | e21dceb3b3 | |
Shadowfacts | 9534f19262 | |
Shadowfacts | e44ae29775 | |
Shadowfacts | a5b30c4243 | |
Shadowfacts | 479ca23e00 | |
Shadowfacts | 5b03e0cf12 | |
Shadowfacts | 7c4bbfd730 | |
Shadowfacts | e19a6528ad | |
Shadowfacts | f5110c773a | |
Shadowfacts | fe1db72f19 | |
Shadowfacts | b4ddb8f533 | |
Shadowfacts | 9a4ddfea3f | |
Shadowfacts | dd8a196630 | |
Shadowfacts | 3da7aacb35 | |
Shadowfacts | 39c8162931 | |
Shadowfacts | fe95cb9e1a | |
Shadowfacts | ec2d510be2 | |
Shadowfacts | 262aadf807 | |
Shadowfacts | 9dce94c014 | |
Shadowfacts | d008b882cb | |
Shadowfacts | 3d13df87f0 | |
Shadowfacts | f0582739cc | |
Shadowfacts | 4c82b1a341 | |
Shadowfacts | b55a96d649 | |
Shadowfacts | 77ac8cbe40 | |
Shadowfacts | e026c9a6c6 | |
Shadowfacts | 3937dde2bf | |
Shadowfacts | 95ebca04d2 | |
Shadowfacts | 0986fa285e | |
Shadowfacts | 1cd3e6adf9 | |
Shadowfacts | 722b81dad9 | |
Shadowfacts | 059f7307b3 | |
Shadowfacts | ee20c95a5d | |
Shadowfacts | be81ffb61f | |
Shadowfacts | 08e0c3769f | |
Shadowfacts | 6d7c9fd553 | |
Shadowfacts | 9b04b75949 | |
Shadowfacts | 273b74ddfb | |
Shadowfacts | ae055f1ffd | |
Shadowfacts | eef9b96a1a | |
Shadowfacts | 29aed65b99 | |
Shadowfacts | 090746f292 | |
Shadowfacts | af300a3559 | |
Shadowfacts | 79eb23ef5d | |
Shadowfacts | 60565f9625 | |
Shadowfacts | 70bedf17a8 | |
Shadowfacts | 392e51eb3e | |
Shadowfacts | 86d5a73c85 | |
Shadowfacts | eaefa366b7 | |
Shadowfacts | 79b23127e9 | |
Shadowfacts | f9b85c87b4 | |
Shadowfacts | 260bedcf10 | |
Shadowfacts | fe09c5e522 | |
Shadowfacts | 985d30a401 | |
Shadowfacts | 794594805c | |
Shadowfacts | 1c708732f2 | |
Shadowfacts | db30471011 | |
Shadowfacts | 2825345c7e | |
Shadowfacts | f3d01c47c3 | |
Shadowfacts | caab5e357a | |
Shadowfacts | 2916d7a72d | |
Shadowfacts | d190636fbd | |
Shadowfacts | 4e4701ead5 | |
Shadowfacts | b07efc150c | |
Shadowfacts | 19fa12391d | |
Shadowfacts | c55ea2e005 | |
Shadowfacts | 47dc00ab8f | |
Shadowfacts | fdcdbced38 | |
Shadowfacts | e70a84274e | |
Shadowfacts | 641ab765a7 | |
Shadowfacts | 986fc5b833 | |
Shadowfacts | cf5b97d9c8 | |
Shadowfacts | 7f0fd119c5 | |
Shadowfacts | b2c7735256 | |
Shadowfacts | 1d815d6cd6 | |
Shadowfacts | f86d3a0ed1 | |
Shadowfacts | 864fd77ecc | |
Shadowfacts | 78da04162f | |
Shadowfacts | 40a742139b | |
Shadowfacts | 8bbc572fa7 | |
Shadowfacts | 2a8e970738 | |
Shadowfacts | 3abb5972b9 | |
Shadowfacts | 0c06d91f6b | |
Shadowfacts | 6cf6db6a8d | |
Shadowfacts | fb11e36467 | |
Shadowfacts | 0fa87e9177 | |
Shadowfacts | 5cb84e271a | |
Shadowfacts | 50f1a9a7de | |
Shadowfacts | 154fc7cd02 | |
Shadowfacts | 01d765fa45 | |
Shadowfacts | 04aad1252a | |
Shadowfacts | 43779e42df | |
Shadowfacts | a5a2cd147e | |
Shadowfacts | 0e91fc239d | |
Shadowfacts | 0e5aab75df | |
Shadowfacts | c715d11fc2 | |
Shadowfacts | 8010e86711 | |
Shadowfacts | a41d27f18c | |
Shadowfacts | 083add273b | |
Shadowfacts | 64365bdf2b | |
Shadowfacts | 6adcad63b3 | |
Shadowfacts | 393a134648 | |
Shadowfacts | ba3e9e7491 | |
Shadowfacts | 920f926b48 | |
Shadowfacts | 6e27399e10 | |
Shadowfacts | c3c19b1994 | |
Shadowfacts | 1f40cc9928 | |
Shadowfacts | 66020b7847 | |
Shadowfacts | 00bf99334f | |
Shadowfacts | 3aef7d4d93 | |
Shadowfacts | a901af6be9 | |
Shadowfacts | b623e348c2 | |
Shadowfacts | 056346cee9 | |
Shadowfacts | 30c04b49e7 | |
Shadowfacts | 848022ec6e | |
Shadowfacts | 39e847bda8 | |
Shadowfacts | 5d751cd994 | |
Shadowfacts | d27bddb2ca | |
Shadowfacts | 36326e4469 | |
Shadowfacts | 6b7904ed52 | |
Shadowfacts | 61c6d63c67 | |
Shadowfacts | c0316f55ef | |
Shadowfacts | 803ba50f53 | |
Shadowfacts | 5d0c59e863 | |
Shadowfacts | c7b4d00da7 | |
Shadowfacts | f2a8b91769 | |
Shadowfacts | ce464dfb9f | |
Shadowfacts | d4bf289716 | |
Shadowfacts | cf48e4e973 | |
Shadowfacts | 2eaeaf3277 | |
Shadowfacts | d396eb0823 | |
Shadowfacts | 35a510e8ed | |
Shadowfacts | 0582812563 | |
Shadowfacts | e581f384e4 | |
Shadowfacts | c42a48ee12 | |
Shadowfacts | 1c9b1b9ac3 | |
Shadowfacts | 82ad3b9fc4 | |
Shadowfacts | 0a89dd3041 | |
Shadowfacts | 40863ef130 | |
Shadowfacts | cd78287a87 | |
Shadowfacts | 04496aca1d | |
Shadowfacts | 5a098df931 | |
Shadowfacts | 9812d4aff2 | |
Shadowfacts | f4f2a5546c | |
Shadowfacts | b220948e2b | |
Shadowfacts | 866edc472d | |
Shadowfacts | 88e4f52b5d | |
Shadowfacts | 98529ca5af | |
Shadowfacts | 6d8c5f632c | |
Shadowfacts | 4fdafa893e | |
Shadowfacts | 9f75106706 | |
Shadowfacts | bbd7d82620 | |
Shadowfacts | 02088b1f55 | |
Shadowfacts | 1e41c8fa17 | |
Shadowfacts | ebbfc7a132 | |
Shadowfacts | aa625a41f5 | |
Shadowfacts | 7fb92c9ce3 | |
Shadowfacts | 90bc9b91de | |
Shadowfacts | d6c506488b | |
Shadowfacts | 5786c24846 | |
Shadowfacts | 2cba168804 | |
Shadowfacts | 49d00bb1b0 | |
Shadowfacts | ee5e049355 | |
Shadowfacts | f53474ac90 | |
Shadowfacts | fa1daa682f | |
Shadowfacts | 030bee1948 | |
Shadowfacts | ed37b16463 | |
Shadowfacts | 2c8ba878b7 | |
Shadowfacts | a0e95d4577 | |
Shadowfacts | 465aedd43f | |
Shadowfacts | 102fe6ed91 | |
Shadowfacts | 7deb4fc5b4 | |
Shadowfacts | 2a419eb87c | |
Shadowfacts | fcab6818b0 | |
Shadowfacts | 80cf1850dd | |
Shadowfacts | e612964464 | |
Shadowfacts | 49a437583e | |
Shadowfacts | 8a513186aa | |
Shadowfacts | d9517047d7 | |
Shadowfacts | bef3388fe8 | |
Shadowfacts | 2e8241d734 | |
Shadowfacts | c9c001d403 | |
Shadowfacts | 4ce8de280e | |
Shadowfacts | 4018d39312 | |
Shadowfacts | ae416bb604 | |
Shadowfacts | 5e9caf9179 | |
Shadowfacts | 3bbbb05083 | |
Shadowfacts | bd3e74c611 | |
Shadowfacts | 2e8c416e04 | |
Shadowfacts | 955f9e5916 | |
Shadowfacts | 17f15db32d | |
Shadowfacts | 1a11dd2a69 | |
Shadowfacts | b5fa0bceab | |
Shadowfacts | c224d11417 | |
Shadowfacts | bebf47f05c | |
Shadowfacts | e76b719c6a | |
Shadowfacts | 478c7b7a23 | |
Shadowfacts | e3cc0df283 | |
Shadowfacts | 9ed05de3ee | |
Shadowfacts | 64f41ea2b7 | |
Shadowfacts | 9af4118dfc | |
Shadowfacts | 64a8f6d733 | |
Shadowfacts | ca76568c79 | |
Shadowfacts | 18e91feb00 | |
Shadowfacts | c5d2e9af68 | |
Shadowfacts | 0691c3b9d6 | |
Shadowfacts | 1ccb450477 | |
Shadowfacts | 7117ce6320 | |
Shadowfacts | 34dccf1f37 | |
Shadowfacts | a3303dc8fb | |
Shadowfacts | d15fa2199e | |
Shadowfacts | fadddeda7f | |
Shadowfacts | b232bec80f | |
Shadowfacts | 1b19a13b05 | |
Shadowfacts | cd5b4c1145 | |
Shadowfacts | b61418e062 | |
Shadowfacts | c7746d3084 | |
Shadowfacts | 315ea39682 | |
Shadowfacts | 44fbbd6a80 | |
Shadowfacts | fa4b5d3542 | |
Shadowfacts | de02c73957 | |
Shadowfacts | 2cebb6bd7d | |
Shadowfacts | 53707593a6 | |
Shadowfacts | 244659c262 | |
Shadowfacts | d4ca39761e | |
Shadowfacts | f87944b47e | |
Shadowfacts | af821081b0 | |
Shadowfacts | 804636dcbb | |
Shadowfacts | 5bed38f661 | |
Shadowfacts | 56de0ab359 | |
Shadowfacts | 387623a309 | |
Shadowfacts | 70bca052c4 | |
Shadowfacts | d9bae42f81 | |
Shadowfacts | a814ee37cc | |
Shadowfacts | 1a8e84f5fa | |
Shadowfacts | 1f56823a17 | |
Shadowfacts | 65d57df949 |
|
@ -1,6 +1,3 @@
|
|||
[submodule "SwiftSoup"]
|
||||
path = SwiftSoup
|
||||
url = git://github.com/scinfu/SwiftSoup.git
|
||||
[submodule "Cache"]
|
||||
path = Cache
|
||||
url = git@github.com:hyperoslo/Cache.git
|
||||
|
|
|
@ -0,0 +1,138 @@
|
|||
# Changelog
|
||||
|
||||
## 2020.1 (10)
|
||||
This build is a hotfix for a couple pressing issues. The changelog for the previous build is included below.
|
||||
|
||||
Bugfixes:
|
||||
- Fix crash when opening Preferences while signed in with a deleted account
|
||||
- Fix visibility and content warning not being copied when replying to a post
|
||||
|
||||
## 2020.1 (9)
|
||||
The marquee feature of this build is the new and improved Compose screen. It's been rewritten to use SwiftUI, is significantly more resilient to data loss, and now shows the toolbar when the main text field is not focused. It also turns out Apple is surprise-releasing iOS 14 very soon (or possibly already has, depending when you're reading this). For those who were not already on the beta train, iOS 14 brings a number of new features including a sidebar on iPadOS and lots and lots of context menus (a home screen widget is coming Soon™).
|
||||
|
||||
Known Issues:
|
||||
- Pasting images to create attachments when composing a post is not currently supported due to an iOS bug (#109)
|
||||
- Full-size previews do not display in context menus for attachments on the Compose screen due to an iOS issue (#110)
|
||||
|
||||
Features/Improvements:
|
||||
- Rewrite Compose screen using SwiftUI
|
||||
- Prevent draft posts being lost if the app crahes or is killed by the system while composing
|
||||
- Show toolbar while post content is not being edited
|
||||
- Save post visibility in drafts
|
||||
- Move Draw Something action out of the context menu
|
||||
- iOS 14: Use context menus for setting post visibility
|
||||
- Show BlurHash previews for attachments on Mastodon
|
||||
- Add Expand All Content Warnings preference (Preferences -> Behavior)
|
||||
- Add Collapse Long Posts preference (Preferences -> Behavior)
|
||||
- Improve image gallery opening animation
|
||||
- Use fade in/out animations for opening/closing gallery and attachment picker when the Reduce Motion system setting is enabled
|
||||
- iOS 14: Also requires the "Prefer Cross Fade" setting be enabled
|
||||
- Slightly reduce default status font sizes
|
||||
- Add "Direct Message" context menu action to Compose button on profile screen
|
||||
- Allow viewing attachments and navigating through posts/accounts on instance public timelines
|
||||
|
||||
Bugfixes:
|
||||
- Fix errors when uploading attachments not displaying
|
||||
- Fix attachments not posting in the correct, user-specified order
|
||||
- Fix accounts displaying with outdated information (avatars, display names, etc.)
|
||||
- Fix Compose not showing button on profile screen
|
||||
- Fix navigation title not being set on profile screen
|
||||
- Fix follow notifications not showing names for users without display names set
|
||||
- iPadOS 14: Fix crash when resizing app in split view mode
|
||||
|
||||
## 2020.1 (8)
|
||||
This is just an emergency build to fix crashes on iOS 13 when selecting attachments. The changelog of the previous build is included below.
|
||||
|
||||
Features/Improvements:
|
||||
- Enlarge tap targets on status reply/favorite/reblog/more buttons
|
||||
- Disable automatic GIF playback when Low Power Mode is enabled
|
||||
- Show custom emoji in user profile field names
|
||||
|
||||
Bugfixes:
|
||||
- Fix crash when attempting to add attachments on iOS 13
|
||||
- Fix potential crashes
|
||||
|
||||
## 2020.1 (7)
|
||||
This is the first update since WWDC and the introduction of iOS 14. As such, most of the focus has been on fixing iOS 14-specific problems. However, there are still a couple new features, both for those on the iOS 14 beta and those not.
|
||||
|
||||
Features/Improvements:
|
||||
- Add toggle between Posts, Posts and Replies, and Media on user profiles
|
||||
- Remove 'Show Replies in Profiles' preference
|
||||
- Limit link preview animation to only link text
|
||||
- Add additional context menu actions for statuses, accounts, and hashtags
|
||||
- Add semi-translucent background to image descriptions, so they're legible against light images
|
||||
- iPadOS 14: Add sidebar
|
||||
- When using multitasking on iPad and switching in and out of "compact" mode, the active tab as well as the navigation history for all tabs will be transferred between the sidebar and tab bar modes.
|
||||
- iOS 14: Use context menus on status/account '...' buttons
|
||||
- iOS 14: Replace 'More' status swipe action with 'Share'
|
||||
|
||||
Bugfixes:
|
||||
- Fix crash when attempting to change post visibility on iPad
|
||||
- Fix attachment view corners not being rounded
|
||||
- Fix crash when viewing instance public timelines
|
||||
- Fix Preferences button not appearing on My Profile tab
|
||||
- Fix tapping current tab bar item not scrolling to top
|
||||
- Fix crash showing audio attachments on Mastodon
|
||||
- Fix timeline refreshing forever
|
||||
- Set app category (fixes usage not being categorized correctly under Screen Time)
|
||||
- iOS 14: Fix crash when searching for instances
|
||||
- iOS 14: Fix crash when displaying accounts with no pinned posts
|
||||
- iOS 14: Fix crash when displaying search results
|
||||
|
||||
## 2020.1 (6)
|
||||
This is the pre-WWDC update with lots of bugfixes and some small features. There will likely be another build this week to fix any pressing issues that arise from iOS 14.
|
||||
|
||||
Features/Improvements:
|
||||
- Add mute/unmute conversation status action
|
||||
- iPadOS: Add pointer interactions to remove attachment button, gallery view share/dismiss buttons
|
||||
- Disable reblog button for direct/followers-only posts
|
||||
- On Pleroma, the reblog button is still enabled for your own followers-only posts to match Pleroma's "Boost to original audience" feature.
|
||||
- Add preference to always display status visibilities below account avatars
|
||||
- Add preference to show reply indicators for statuses in timelines
|
||||
- Show share/dismiss controls and image description for gifv attachments
|
||||
- 'Share' is currently disabled for gifv attachments, it will be enabled in a future build
|
||||
- Add crash report helper
|
||||
- If the app detects that it crashed the last time it was running, it will allow you to review the crash report and email it to me
|
||||
- Add Recognize Text context menu option for images on the Compose screen
|
||||
- This uses iOS' builtin Vision framework to perform on-device OCR and generate an image description from the recognized text
|
||||
- Tweak attachment previews to always have a 16:9 aspect ratio
|
||||
|
||||
Bugfixes:
|
||||
- Fix account/status More actions not working
|
||||
- Improve share sheet loading speed
|
||||
- Fix crash when loading bookmarks
|
||||
- Prompt for Photos access before showing photo picker. Prevents empty sheet displaying.
|
||||
- Fix profile fields not displaying and improve layout
|
||||
- Fix profile header image not displaying the first time an account is loaded
|
||||
- Don't show Follow action for your own account
|
||||
- Fix attachments on the Compose screen being cut-off above the home indicator on iPhone X-style devices
|
||||
- Fix audio being played by other apps pausing when displaying a gifv attachment on Mastodon
|
||||
|
||||
|
||||
## 2020.1 (5)
|
||||
The main focus of this update has been switching to using CoreData internally to cache/synchronize the most up-to-date versions of all statuses. Currently, this does not provide any new functionality, however, it lays the groundwork for several significant features coming in the future, including multiple window support on iPadOS and state restoration/persistence between launches.
|
||||
|
||||
Even though there aren't a huge number of new features in this build, a great deal has changed under the hood. As such, this build may suffer somewhat in the stability department. Please bear with me and report any issues you encounter; you can send me a message on the fediverse, email me at me@shadowfacts.net, or file an issue on the project issue tracker at https://git.shadowfacts.net/shadowfacts/Tusker/issues. Thank you!
|
||||
|
||||
Features:
|
||||
- iPadOS: Add pointer interactions to status action buttons and profile header button
|
||||
- iPadOS: Allow scrolling w/ trackpad/magic mouse to dismiss attachment gallery
|
||||
- iPadOS: Enable interactive push gesture with trackpad/magic mouse
|
||||
- Add drawing attachments using PencilKit
|
||||
- Long-press to open context menu on the 'Add Attachment' button on the Compose screen, select 'Draw Something'
|
||||
- Supports Apple Pencil on iPad, including tilt and pressure sensitivity
|
||||
- Add avatar and instance domain in accounts switcher in Preferences
|
||||
- Show gifv attachments on Mastodon
|
||||
- Currently doesn't show attachment description or share/close buttons
|
||||
- Add 'Clear Cache' option to Preferences -> Advanced for debugging
|
||||
|
||||
Bugfixes:
|
||||
- Fix size of attachment previews in context menu
|
||||
- Fix previewing audio/video attachments
|
||||
- Fix incorrect image size during attachment expand/shrink animation
|
||||
- Prevent avatars in grouped action notification from overflowing the cell and hiding the timestamp
|
||||
- Fix text in conversation main statuses not being de-selectable
|
||||
- Fix scroll-to-top sometimes not scrolling all the way to the top
|
||||
- Fix account profile descriptions being squashed in the follow notification account list
|
||||
|
||||
|
2
Gifu
|
@ -1 +1 @@
|
|||
Subproject commit ed572f53ce58b8e23499abeb3a926033cbe480f7
|
||||
Subproject commit 9b1a6461aa3b5f66cb0ed3a50c5523db0b4fb007
|
|
@ -26,7 +26,7 @@ public class Client {
|
|||
|
||||
public var timeoutInterval: TimeInterval = 60
|
||||
|
||||
lazy var decoder: JSONDecoder = {
|
||||
static let decoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
|
||||
|
@ -36,6 +36,16 @@ public class Client {
|
|||
return decoder
|
||||
}()
|
||||
|
||||
static let encoder: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
|
||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
encoder.dateEncodingStrategy = .formatted(formatter)
|
||||
return encoder
|
||||
}()
|
||||
|
||||
public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
|
||||
self.baseURL = baseURL
|
||||
self.accessToken = accessToken
|
||||
|
@ -50,29 +60,24 @@ public class Client {
|
|||
|
||||
let task = session.dataTask(with: request) { data, response, error in
|
||||
if let error = error {
|
||||
completion(.failure(error))
|
||||
completion(.failure(.networkError(error)))
|
||||
return
|
||||
}
|
||||
guard let data = data,
|
||||
let response = response as? HTTPURLResponse else {
|
||||
completion(.failure(Error.invalidResponse))
|
||||
completion(.failure(.invalidResponse))
|
||||
return
|
||||
}
|
||||
guard response.statusCode == 200 else {
|
||||
let mastodonError = try? self.decoder.decode(MastodonError.self, from: data)
|
||||
let error = mastodonError.flatMap { Error.mastodonError($0.description) } ?? Error.unknownError
|
||||
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
|
||||
let error: Error = mastodonError.flatMap { .mastodonError($0.description) } ?? .unexpectedStatus(response.statusCode)
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
guard let result = try? self.decoder.decode(Result.self, from: data) else {
|
||||
completion(.failure(Error.invalidModel))
|
||||
guard let result = try? Client.decoder.decode(Result.self, from: data) else {
|
||||
completion(.failure(.invalidModel))
|
||||
return
|
||||
}
|
||||
if var result = result as? ClientModel {
|
||||
result.client = self
|
||||
} else if var result = result as? [ClientModel] {
|
||||
result.client = self
|
||||
}
|
||||
let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
|
||||
|
||||
completion(.success(result, pagination))
|
||||
|
@ -97,7 +102,7 @@ public class Client {
|
|||
|
||||
// MARK: - Authorization
|
||||
public func registerApp(name: String, redirectURI: String, scopes: [Scope], website: URL? = nil, completion: @escaping Callback<RegisteredApplication>) {
|
||||
let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: .parameters([
|
||||
let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: ParametersBody([
|
||||
"client_name" => name,
|
||||
"redirect_uris" => redirectURI,
|
||||
"scopes" => scopes.scopeString,
|
||||
|
@ -114,7 +119,7 @@ public class Client {
|
|||
}
|
||||
|
||||
public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) {
|
||||
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: .parameters([
|
||||
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: ParametersBody([
|
||||
"client_id" => clientID,
|
||||
"client_secret" => clientSecret,
|
||||
"grant_type" => "authorization_code",
|
||||
|
@ -173,13 +178,13 @@ public class Client {
|
|||
}
|
||||
|
||||
public static func block(domain: String) -> Request<Empty> {
|
||||
return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: .parameters([
|
||||
return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: ParametersBody([
|
||||
"domain" => domain
|
||||
]))
|
||||
}
|
||||
|
||||
public static func unblock(domain: String) -> Request<Empty> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: .parameters([
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: ParametersBody([
|
||||
"domain" => domain
|
||||
]))
|
||||
}
|
||||
|
@ -190,7 +195,7 @@ public class Client {
|
|||
}
|
||||
|
||||
public static func createFilter(phrase: String, context: [Filter.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
|
||||
return Request<Filter>(method: .post, path: "/api/v1/filters", body: .parameters([
|
||||
return Request<Filter>(method: .post, path: "/api/v1/filters", body: ParametersBody([
|
||||
"phrase" => phrase,
|
||||
"irreversible" => irreversible,
|
||||
"whole_word" => wholeWord,
|
||||
|
@ -214,7 +219,7 @@ public class Client {
|
|||
}
|
||||
|
||||
public static func followRemote(acct: String) -> Request<Account> {
|
||||
return Request<Account>(method: .post, path: "/api/v1/follows", body: .parameters(["uri" => acct]))
|
||||
return Request<Account>(method: .post, path: "/api/v1/follows", body: ParametersBody(["uri" => acct]))
|
||||
}
|
||||
|
||||
// MARK: - Lists
|
||||
|
@ -227,12 +232,12 @@ public class Client {
|
|||
}
|
||||
|
||||
public static func createList(title: String) -> Request<List> {
|
||||
return Request<List>(method: .post, path: "/api/v1/lists", body: .parameters(["title" => title]))
|
||||
return Request<List>(method: .post, path: "/api/v1/lists", body: ParametersBody(["title" => title]))
|
||||
}
|
||||
|
||||
// MARK: - Media
|
||||
public static func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil) -> Request<Attachment> {
|
||||
return Request<Attachment>(method: .post, path: "/api/v1/media", body: .formData([
|
||||
return Request<Attachment>(method: .post, path: "/api/v1/media", body: FormDataBody([
|
||||
"description" => description,
|
||||
"focus" => focus
|
||||
], attachment))
|
||||
|
@ -264,7 +269,7 @@ public class Client {
|
|||
}
|
||||
|
||||
public static func report(account: Account, statuses: [Status], comment: String) -> Request<Report> {
|
||||
return Request<Report>(method: .post, path: "/api/v1/reports", body: .parameters([
|
||||
return Request<Report>(method: .post, path: "/api/v1/reports", body: ParametersBody([
|
||||
"account_id" => account.id,
|
||||
"comment" => comment
|
||||
] + "status_ids" => statuses.map { $0.id }))
|
||||
|
@ -292,7 +297,7 @@ public class Client {
|
|||
spoilerText: String? = nil,
|
||||
visibility: Status.Visibility? = nil,
|
||||
language: String? = nil) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses", body: .parameters([
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
|
||||
"status" => text,
|
||||
"content_type" => contentType.mimeType,
|
||||
"in_reply_to_id" => inReplyTo,
|
||||
|
@ -319,12 +324,32 @@ public class Client {
|
|||
}
|
||||
|
||||
extension Client {
|
||||
public enum Error: Swift.Error {
|
||||
case unknownError
|
||||
public enum Error: LocalizedError {
|
||||
case networkError(Swift.Error)
|
||||
case unexpectedStatus(Int)
|
||||
case invalidRequest
|
||||
case invalidResponse
|
||||
case invalidModel
|
||||
case mastodonError(String)
|
||||
|
||||
public var localizedDescription: String {
|
||||
switch self {
|
||||
case .networkError(let error):
|
||||
return "Network Error: \(error.localizedDescription)"
|
||||
// todo: support more status codes
|
||||
case .unexpectedStatus(413):
|
||||
return "HTTP 413: Payload Too Large"
|
||||
case .unexpectedStatus(let code):
|
||||
return "HTTP Code \(code)"
|
||||
case .invalidRequest:
|
||||
return "Invalid Request"
|
||||
case .invalidResponse:
|
||||
return "Invalid Response"
|
||||
case .invalidModel:
|
||||
return "Invalid Model"
|
||||
case .mastodonError(let error):
|
||||
return "Server Error: \(error)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
//
|
||||
// ClientModel.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 9/9/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol ClientModel {
|
||||
var client: Client! { get set }
|
||||
}
|
||||
|
||||
extension Array where Element == ClientModel {
|
||||
var client: Client! {
|
||||
get {
|
||||
return first?.client
|
||||
}
|
||||
set {
|
||||
for var el in self {
|
||||
el.client = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element: ClientModel {
|
||||
var client: Client! {
|
||||
get {
|
||||
return first?.client
|
||||
}
|
||||
set {
|
||||
for var el in self {
|
||||
el.client = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class Account: Decodable {
|
||||
public final class Account: AccountProtocol, Decodable {
|
||||
public let id: String
|
||||
public let username: String
|
||||
public let acct: String
|
||||
|
@ -27,7 +27,7 @@ public class Account: Decodable {
|
|||
public private(set) var emojis: [Emoji]
|
||||
public let moved: Bool?
|
||||
public let movedTo: Account?
|
||||
public let fields: [Field]?
|
||||
public let fields: [Field]
|
||||
public let bot: Bool?
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
|
@ -47,9 +47,9 @@ public class Account: Decodable {
|
|||
self.avatar = try container.decode(URL.self, forKey: .avatar)
|
||||
self.avatarStatic = try container.decode(URL.self, forKey: .avatarStatic)
|
||||
self.header = try container.decode(URL.self, forKey: .header)
|
||||
self.headerStatic = try container.decode(URL.self, forKey: .url)
|
||||
self.headerStatic = try container.decode(URL.self, forKey: .headerStatic)
|
||||
self.emojis = try container.decode([Emoji].self, forKey: .emojis)
|
||||
self.fields = try? container.decode([Field].self, forKey: .fields)
|
||||
self.fields = (try? container.decode([Field].self, forKey: .fields)) ?? []
|
||||
self.bot = try? container.decode(Bool.self, forKey: .bot)
|
||||
|
||||
if let moved = try? container.decode(Bool.self, forKey: .moved) {
|
||||
|
@ -115,7 +115,7 @@ public class Account: Decodable {
|
|||
}
|
||||
|
||||
public static func mute(_ account: Account, notifications: Bool? = nil) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/mute", body: .parameters([
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/mute", body: ParametersBody([
|
||||
"notifications" => notifications
|
||||
]))
|
||||
}
|
||||
|
|
|
@ -8,18 +8,19 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class Attachment: Decodable {
|
||||
public class Attachment: Codable {
|
||||
public let id: String
|
||||
public let kind: Kind
|
||||
public let url: URL
|
||||
public let remoteURL: URL?
|
||||
public let previewURL: URL
|
||||
public let previewURL: URL?
|
||||
public let textURL: URL?
|
||||
public let meta: Metadata?
|
||||
public let description: String?
|
||||
public let blurHash: String?
|
||||
|
||||
public static func update(_ attachment: Attachment, focus: (Float, Float)?, description: String?) -> Request<Attachment> {
|
||||
return Request<Attachment>(method: .put, path: "/api/v1/media/\(attachment.id)", body: .formData([
|
||||
return Request<Attachment>(method: .put, path: "/api/v1/media/\(attachment.id)", body: FormDataBody([
|
||||
"description" => (description ?? attachment.description),
|
||||
"focus" => focus
|
||||
], nil))
|
||||
|
@ -29,20 +30,13 @@ public class Attachment: Decodable {
|
|||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.id = try container.decode(String.self, forKey: .id)
|
||||
self.kind = try container.decode(Kind.self, forKey: .kind)
|
||||
self.url = URL(lenient: try container.decode(String.self, forKey: .url))!
|
||||
if let remote = try? container.decode(String.self, forKey: .remoteURL) {
|
||||
self.remoteURL = URL(lenient: remote.replacingOccurrences(of: " ", with: "%20"))
|
||||
} else {
|
||||
self.remoteURL = nil
|
||||
}
|
||||
self.previewURL = URL(lenient: try container.decode(String.self, forKey: .previewURL).replacingOccurrences(of: " ", with: "%20"))!
|
||||
if let text = try? container.decode(String.self, forKey: .textURL) {
|
||||
self.textURL = URL(lenient: text.replacingOccurrences(of: " ", with: "%20"))
|
||||
} else {
|
||||
self.textURL = nil
|
||||
}
|
||||
self.meta = try? container.decode(Metadata.self, forKey: .meta)
|
||||
self.description = try? container.decode(String.self, forKey: .description)
|
||||
self.url = try container.decode(URL.self, forKey: .url)
|
||||
self.previewURL = try? container.decode(URL?.self, forKey: .previewURL)
|
||||
self.remoteURL = try? container.decode(URL?.self, forKey: .remoteURL)
|
||||
self.textURL = try? container.decode(URL?.self, forKey: .textURL)
|
||||
self.meta = try? container.decode(Metadata?.self, forKey: .meta)
|
||||
self.description = try? container.decode(String?.self, forKey: .description)
|
||||
self.blurHash = try? container.decode(String?.self, forKey: .blurHash)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
|
@ -54,11 +48,12 @@ public class Attachment: Decodable {
|
|||
case textURL = "text_url"
|
||||
case meta
|
||||
case description
|
||||
case blurHash = "blurhash"
|
||||
}
|
||||
}
|
||||
|
||||
extension Attachment {
|
||||
public enum Kind: String, Decodable {
|
||||
public enum Kind: String, Codable {
|
||||
case image
|
||||
case video
|
||||
case gifv
|
||||
|
@ -68,7 +63,7 @@ extension Attachment {
|
|||
}
|
||||
|
||||
extension Attachment {
|
||||
public class Metadata: Decodable {
|
||||
public struct Metadata: Codable {
|
||||
public let length: String?
|
||||
public let duration: Float?
|
||||
public let audioEncoding: String?
|
||||
|
@ -99,7 +94,7 @@ extension Attachment {
|
|||
}
|
||||
}
|
||||
|
||||
public class ImageMetadata: Decodable {
|
||||
public struct ImageMetadata: Codable {
|
||||
public let width: Int?
|
||||
public let height: Int?
|
||||
public let size: String?
|
||||
|
@ -113,14 +108,3 @@ extension Attachment {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension URL {
|
||||
private static let allowedChars = CharacterSet.urlHostAllowed.union(.urlPathAllowed).union(.urlQueryAllowed)
|
||||
|
||||
init?(lenient string: String) {
|
||||
guard let escaped = string.addingPercentEncoding(withAllowedCharacters: URL.allowedChars) else {
|
||||
return nil
|
||||
}
|
||||
self.init(string: escaped)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,6 +22,23 @@ public class Card: Decodable {
|
|||
public let width: Int?
|
||||
public let height: Int?
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.url = try container.decode(URL.self, forKey: .url)
|
||||
self.title = try container.decode(String.self, forKey: .title)
|
||||
self.description = try container.decode(String.self, forKey: .description)
|
||||
self.kind = try container.decode(Kind.self, forKey: .kind)
|
||||
self.image = try? container.decode(URL.self, forKey: .image)
|
||||
self.authorName = try? container.decode(String.self, forKey: .authorName)
|
||||
self.authorURL = try? container.decode(URL.self, forKey: .authorURL)
|
||||
self.providerName = try? container.decode(String.self, forKey: .providerName)
|
||||
self.providerURL = try? container.decode(URL.self, forKey: .providerURL)
|
||||
self.html = try? container.decode(String.self, forKey: .html)
|
||||
self.width = try? container.decode(Int.self, forKey: .width)
|
||||
self.height = try? container.decode(Int.self, forKey: .height)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case url
|
||||
case title
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class Emoji: Decodable {
|
||||
public class Emoji: Codable {
|
||||
public let shortcode: String
|
||||
public let url: URL
|
||||
public let staticURL: URL
|
||||
|
|
|
@ -23,7 +23,7 @@ public class Filter: Decodable {
|
|||
}
|
||||
|
||||
public static func update(_ filter: Filter, phrase: String? = nil, context: [Context]? = nil, irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
|
||||
return Request<Filter>(method: .put, path: "/api/v1/filters/\(filter.id)", body: .parameters([
|
||||
return Request<Filter>(method: .put, path: "/api/v1/filters/\(filter.id)", body: ParametersBody([
|
||||
"phrase" => (phrase ?? filter.phrase),
|
||||
"irreversible" => (irreversible ?? filter.irreversible),
|
||||
"whole_word" => (wholeWord ?? filter.wholeWord),
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class List: Decodable {
|
||||
public class List: Decodable, Equatable, Hashable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
|
||||
|
@ -16,6 +16,14 @@ public class List: Decodable {
|
|||
return .list(id: id)
|
||||
}
|
||||
|
||||
public static func ==(lhs: List, rhs: List) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts")
|
||||
request.range = range
|
||||
|
@ -23,7 +31,7 @@ public class List: Decodable {
|
|||
}
|
||||
|
||||
public static func update(_ list: List, title: String) -> Request<List> {
|
||||
return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: .parameters(["title" => title]))
|
||||
return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: ParametersBody(["title" => title]))
|
||||
}
|
||||
|
||||
public static func delete(_ list: List) -> Request<Empty> {
|
||||
|
@ -31,13 +39,13 @@ public class List: Decodable {
|
|||
}
|
||||
|
||||
public static func add(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
|
||||
return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: .parameters(
|
||||
return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
|
||||
"account_ids" => accountIDs
|
||||
))
|
||||
}
|
||||
|
||||
public static func remove(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: .parameters(
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
|
||||
"account_ids" => accountIDs
|
||||
))
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class Mention: Decodable {
|
||||
public class Mention: Codable {
|
||||
public let url: URL
|
||||
public let username: String
|
||||
public let acct: String
|
||||
|
|
|
@ -15,8 +15,26 @@ public class Notification: Decodable {
|
|||
public let account: Account
|
||||
public let status: Status?
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.id = try container.decode(String.self, forKey: .id)
|
||||
if let kind = try? container.decode(Kind.self, forKey: .kind) {
|
||||
self.kind = kind
|
||||
} else {
|
||||
self.kind = .unknown
|
||||
}
|
||||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
self.account = try container.decode(Account.self, forKey: .account)
|
||||
if container.contains(.status) {
|
||||
self.status = try container.decode(Status.self, forKey: .status)
|
||||
} else {
|
||||
self.status = nil
|
||||
}
|
||||
}
|
||||
|
||||
public static func dismiss(id notificationID: String) -> Request<Empty> {
|
||||
return Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: .parameters([
|
||||
return Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: ParametersBody([
|
||||
"id" => notificationID
|
||||
]))
|
||||
}
|
||||
|
@ -37,6 +55,7 @@ extension Notification {
|
|||
case favourite
|
||||
case follow
|
||||
case followRequest = "follow_request"
|
||||
case unknown
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
//
|
||||
// AccountProtocol.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 4/11/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol AccountProtocol {
|
||||
associatedtype Account: AccountProtocol
|
||||
|
||||
var id: String { get }
|
||||
var username: String { get }
|
||||
var acct: String { get }
|
||||
var displayName: String { get }
|
||||
var locked: Bool { get }
|
||||
var createdAt: Date { get }
|
||||
var followersCount: Int { get }
|
||||
var followingCount: Int { get }
|
||||
var statusesCount: Int { get }
|
||||
var note: String { get }
|
||||
var url: URL { get }
|
||||
var avatar: URL { get }
|
||||
var header: URL { get }
|
||||
var moved: Bool? { get }
|
||||
var bot: Bool? { get }
|
||||
|
||||
var movedTo: Account? { get }
|
||||
var emojis: [Emoji] { get }
|
||||
var fields: [Pachyderm.Account.Field] { get }
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
//
|
||||
// StatusProtocol.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 4/11/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol StatusProtocol {
|
||||
associatedtype Status: StatusProtocol
|
||||
associatedtype Account: AccountProtocol
|
||||
|
||||
var id: String { get }
|
||||
var uri: String { get }
|
||||
var inReplyToID: String? { get }
|
||||
var inReplyToAccountID: String? { get }
|
||||
var content: String { get }
|
||||
var createdAt: Date { get }
|
||||
var reblogsCount: Int { get }
|
||||
var favouritesCount: Int { get }
|
||||
var reblogged: Bool { get }
|
||||
var favourited: Bool { get }
|
||||
var sensitive: Bool { get }
|
||||
var spoilerText: String { get }
|
||||
var visibility: Pachyderm.Status.Visibility { get }
|
||||
var applicationName: String? { get }
|
||||
var pinned: Bool? { get }
|
||||
var bookmarked: Bool? { get }
|
||||
|
||||
var account: Account { get }
|
||||
var reblog: Status? { get }
|
||||
var attachments: [Attachment] { get }
|
||||
var emojis: [Emoji] { get }
|
||||
var hashtags: [Hashtag] { get }
|
||||
var mentions: [Mention] { get }
|
||||
}
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class Status: Decodable {
|
||||
public final class Status: /*StatusProtocol,*/ Decodable {
|
||||
public let id: String
|
||||
public let uri: String
|
||||
public let url: URL?
|
||||
|
@ -36,23 +36,26 @@ public class Status: Decodable {
|
|||
public let language: String?
|
||||
public let pinned: Bool?
|
||||
public let bookmarked: Bool?
|
||||
public let card: Card?
|
||||
|
||||
public static func getContext(_ status: Status) -> Request<ConversationContext> {
|
||||
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(status.id)/context")
|
||||
public var applicationName: String? { application?.name }
|
||||
|
||||
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
|
||||
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(statusID)/context")
|
||||
}
|
||||
|
||||
public static func getCard(_ status: Status) -> Request<Card> {
|
||||
return Request<Card>(method: .get, path: "/api/v1/statuses/\(status.id)/card")
|
||||
}
|
||||
|
||||
public static func getFavourites(_ status: Status, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/favourited_by")
|
||||
public static func getFavourites(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/favourited_by")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
public static func getReblogs(_ status: Status, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/reblogged_by")
|
||||
public static func getReblogs(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/reblogged_by")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
@ -61,44 +64,44 @@ public class Status: Decodable {
|
|||
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(status.id)")
|
||||
}
|
||||
|
||||
public static func reblog(_ status: Status) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/reblog")
|
||||
public static func reblog(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/reblog")
|
||||
}
|
||||
|
||||
public static func unreblog(_ status: Status) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unreblog")
|
||||
public static func unreblog(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unreblog")
|
||||
}
|
||||
|
||||
public static func favourite(_ status: Status) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/favourite")
|
||||
public static func favourite(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/favourite")
|
||||
}
|
||||
|
||||
public static func unfavourite(_ status: Status) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unfavourite")
|
||||
public static func unfavourite(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unfavourite")
|
||||
}
|
||||
|
||||
public static func pin(_ status: Status) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/pin")
|
||||
public static func pin(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/pin")
|
||||
}
|
||||
|
||||
public static func unpin(_ status: Status) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unpin")
|
||||
public static func unpin(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unpin")
|
||||
}
|
||||
|
||||
public static func bookmark(_ status: Status) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/bookmark")
|
||||
public static func bookmark(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/bookmark")
|
||||
}
|
||||
|
||||
public static func unbookmark(_ status: Status) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unbookmark")
|
||||
public static func unbookmark(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unbookmark")
|
||||
}
|
||||
|
||||
public static func muteConversation(_ status: Status) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/mute")
|
||||
public static func muteConversation(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/mute")
|
||||
}
|
||||
|
||||
public static func unmuteConversation(_ status: Status) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unmute")
|
||||
public static func unmuteConversation(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unmute")
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
|
@ -128,6 +131,7 @@ public class Status: Decodable {
|
|||
case language
|
||||
case pinned
|
||||
case bookmarked
|
||||
case card
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,56 +8,82 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
enum Body {
|
||||
case parameters([Parameter]?)
|
||||
case formData([Parameter]?, FormAttachment?)
|
||||
case empty
|
||||
protocol Body {
|
||||
var mimeType: String? { get }
|
||||
var data: Data? { get }
|
||||
}
|
||||
|
||||
extension Body {
|
||||
private static let boundary: String = "PachydermBoundary"
|
||||
struct EmptyBody: Body {
|
||||
var mimeType: String? { nil }
|
||||
var data: Data? { nil }
|
||||
}
|
||||
|
||||
struct ParametersBody: Body {
|
||||
let parameters: [Parameter]?
|
||||
|
||||
init(_ parmaeters: [Parameter]?) {
|
||||
self.parameters = parmaeters
|
||||
}
|
||||
|
||||
var mimeType: String? {
|
||||
if parameters == nil || parameters!.isEmpty {
|
||||
return nil
|
||||
}
|
||||
return "application/x-www-form-urlencoded; charset=utf-8"
|
||||
}
|
||||
|
||||
var data: Data? {
|
||||
switch self {
|
||||
case let .parameters(parameters):
|
||||
return parameters?.urlEncoded.data(using: .utf8)
|
||||
case let .formData(parameters, attachment):
|
||||
}
|
||||
}
|
||||
|
||||
struct FormDataBody: Body {
|
||||
private static let boundary = "PachydermBoundary"
|
||||
|
||||
let parameters: [Parameter]?
|
||||
let attachment: FormAttachment?
|
||||
|
||||
init(_ parameters: [Parameter]?, _ attachment: FormAttachment?) {
|
||||
self.parameters = parameters
|
||||
self.attachment = attachment
|
||||
}
|
||||
|
||||
var mimeType: String? {
|
||||
if parameters == nil && attachment == nil {
|
||||
return nil
|
||||
}
|
||||
return "multipart/form-data; boundary=\(FormDataBody.boundary)"
|
||||
}
|
||||
|
||||
var data: Data? {
|
||||
var data = Data()
|
||||
parameters?.forEach { param in
|
||||
guard let value = param.value else { return }
|
||||
data.append("--\(Body.boundary)\r\n")
|
||||
data.append("--\(FormDataBody.boundary)\r\n")
|
||||
data.append("Content-Disposition: form-data; name=\"\(param.name)\"\r\n\r\n")
|
||||
data.append("\(value)\r\n")
|
||||
}
|
||||
if let attachment = attachment {
|
||||
data.append("--\(Body.boundary)\r\n")
|
||||
data.append("--\(FormDataBody.boundary)\r\n")
|
||||
data.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(attachment.fileName)\"\r\n")
|
||||
data.append("Content-Type: \(attachment.mimeType)\r\n\r\n")
|
||||
data.append(attachment.data)
|
||||
data.append("\r\n")
|
||||
}
|
||||
|
||||
data.append("--\(Body.boundary)--\r\n")
|
||||
data.append("--\(FormDataBody.boundary)--\r\n")
|
||||
return data
|
||||
case .empty:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var mimeType: String? {
|
||||
switch self {
|
||||
case let .parameters(parameters):
|
||||
if parameters == nil {
|
||||
return nil
|
||||
}
|
||||
return "application/x-www-form-urlencoded; charset=utf-8"
|
||||
case let .formData(parameters, attachment):
|
||||
if parameters == nil && attachment == nil {
|
||||
return nil
|
||||
}
|
||||
return "multipart/form-data; boundary=\(Body.boundary)"
|
||||
case .empty:
|
||||
return nil
|
||||
}
|
||||
struct JsonBody<T: Encodable>: Body {
|
||||
let value: T
|
||||
|
||||
init(_ value: T) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
var mimeType: String? { "application/json" }
|
||||
|
||||
var data: Data? { try? Client.encoder.encode(value) }
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ public struct Request<ResultType: Decodable> {
|
|||
let body: Body
|
||||
var queryParameters: [Parameter]
|
||||
|
||||
init(method: Method, path: String, body: Body = .empty, queryParameters: [Parameter] = []) {
|
||||
init(method: Method, path: String, body: Body = EmptyBody(), queryParameters: [Parameter] = []) {
|
||||
self.method = method
|
||||
self.path = path
|
||||
self.body = body
|
||||
|
|
|
@ -10,5 +10,5 @@ import Foundation
|
|||
|
||||
public enum Response<Result: Decodable> {
|
||||
case success(Result, Pagination?)
|
||||
case failure(Error)
|
||||
case failure(Client.Error)
|
||||
}
|
||||
|
|
|
@ -22,16 +22,16 @@ public class InstanceSelector {
|
|||
let request = URLRequest(url: url)
|
||||
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
|
||||
if let error = error {
|
||||
completion(.failure(error))
|
||||
completion(.failure(.networkError(error)))
|
||||
return
|
||||
}
|
||||
guard let data = data,
|
||||
let response = response as? HTTPURLResponse else {
|
||||
completion(.failure(Client.Error.invalidResponse))
|
||||
completion(.failure(.invalidResponse))
|
||||
return
|
||||
}
|
||||
guard response.statusCode == 200 else {
|
||||
completion(.failure(Client.Error.unknownError))
|
||||
completion(.failure(.unexpectedStatus(response.statusCode)))
|
||||
return
|
||||
}
|
||||
guard let result = try? decoder.decode([Instance].self, from: data) else {
|
||||
|
|
|
@ -9,14 +9,14 @@
|
|||
import Foundation
|
||||
|
||||
public class NotificationGroup {
|
||||
public let notificationIDs: [String]
|
||||
public let notifications: [Notification]
|
||||
public let id: String
|
||||
public let kind: Notification.Kind
|
||||
public let statusState: StatusState?
|
||||
|
||||
init?(notifications: [Notification]) {
|
||||
guard !notifications.isEmpty else { return nil }
|
||||
self.notificationIDs = notifications.map { $0.id }
|
||||
self.notifications = notifications
|
||||
self.id = notifications.first!.id
|
||||
self.kind = notifications.first!.kind
|
||||
if kind == .mention {
|
||||
|
@ -27,18 +27,24 @@ public class NotificationGroup {
|
|||
}
|
||||
|
||||
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
||||
return notifications.reduce(into: [[Notification]]()) { (groups, notification) in
|
||||
if allowedTypes.contains(notification.kind),
|
||||
let lastGroup = groups.last,
|
||||
let firstStatus = lastGroup.first,
|
||||
firstStatus.kind == notification.kind,
|
||||
firstStatus.status?.id == notification.status?.id {
|
||||
|
||||
var groups = [[Notification]]()
|
||||
for notification in notifications {
|
||||
if allowedTypes.contains(notification.kind) {
|
||||
if let lastGroup = groups.last, let firstNotification = lastGroup.first, firstNotification.kind == notification.kind, firstNotification.status?.id == notification.status?.id {
|
||||
groups[groups.count - 1].append(notification)
|
||||
} else {
|
||||
continue
|
||||
} else if groups.count >= 2 {
|
||||
let secondToLastGroup = groups[groups.count - 2]
|
||||
if allowedTypes.contains(groups[groups.count - 1][0].kind), let firstNotification = secondToLastGroup.first, firstNotification.kind == notification.kind, firstNotification.status?.id == notification.status?.id {
|
||||
groups[groups.count - 2].append(notification)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groups.append([notification])
|
||||
}
|
||||
}.map {
|
||||
return groups.map {
|
||||
NotificationGroup(notifications: $0)!
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
Subproject commit f445c9067d28346e828e615e2b43cb07b20bca35
|
|
@ -27,15 +27,6 @@
|
|||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D6D4DDCB212518A000E1C4BB"
|
||||
BuildableName = "Tusker.app"
|
||||
BlueprintName = "Tusker"
|
||||
ReferencedContainer = "container:Tusker.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
|
@ -92,6 +83,12 @@
|
|||
ReferencedContainer = "container:Tusker.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
<CommandLineArgument
|
||||
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
|
||||
isEnabled = "YES">
|
||||
</CommandLineArgument>
|
||||
</CommandLineArguments>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
|
|
@ -10,9 +10,6 @@
|
|||
<FileRef
|
||||
location = "group:Cache/Cache.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:SwiftSoup/SwiftSoup.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Gifu/Gifu.xcodeproj">
|
||||
</FileRef>
|
||||
|
|
|
@ -1,14 +1,32 @@
|
|||
{
|
||||
"object": {
|
||||
"pins": [
|
||||
{
|
||||
"package": "PLCrashReporter",
|
||||
"repositoryURL": "https://github.com/microsoft/plcrashreporter",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "6b7ca9a2faad6ea990ff60b0a3ee4fdf3db59150",
|
||||
"version": "1.7.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SheetController",
|
||||
"repositoryURL": "https://git.shadowfacts.net/shadowfacts/SheetController.git",
|
||||
"state": {
|
||||
"branch": "master",
|
||||
"revision": "6ee1ad24ec8620f5c17416d6141643f0787708ba",
|
||||
"revision": "aa0f5192eaf19d01c89dbfa9ec5878a700376f23",
|
||||
"version": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SwiftSoup",
|
||||
"repositoryURL": "https://github.com/scinfu/SwiftSoup.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "774dc9c7213085db8aa59595e27c1cd22e428904",
|
||||
"version": "2.3.2"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
class AccountActivity: MastodonActivity {
|
||||
|
||||
|
@ -15,17 +14,17 @@ class AccountActivity: MastodonActivity {
|
|||
return .action
|
||||
}
|
||||
|
||||
var account: Account?
|
||||
var account: AccountMO?
|
||||
|
||||
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
|
||||
for case is Account in activityItems {
|
||||
for case is AccountMO in activityItems {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override func prepare(withActivityItems activityItems: [Any]) {
|
||||
for case let account as Account in activityItems {
|
||||
for case let account as AccountMO in activityItems {
|
||||
self.account = account
|
||||
return
|
||||
}
|
||||
|
|
|
@ -29,9 +29,7 @@ class FollowAccountActivity: AccountActivity {
|
|||
|
||||
let request = Account.follow(account.id)
|
||||
mastodonController.run(request) { (response) in
|
||||
if case let .success(relationship, _) = response {
|
||||
self.mastodonController.cache.add(relationship: relationship)
|
||||
} else {
|
||||
if case .failure(_) = response {
|
||||
// todo: display error message
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||
fatalError()
|
||||
|
|
|
@ -28,7 +28,9 @@ class SendMessageActivity: AccountActivity {
|
|||
override var activityViewController: UIViewController? {
|
||||
guard let account = account else { return nil }
|
||||
|
||||
return UINavigationController(rootViewController: ComposeViewController(mentioningAcct: account.acct, mastodonController: mastodonController))
|
||||
let draft = mastodonController.createDraft(mentioningAcct: account.acct)
|
||||
let compose = ComposeHostingController(draft: draft, mastodonController: mastodonController)
|
||||
return UINavigationController(rootViewController: compose)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -29,9 +29,7 @@ class UnfollowAccountActivity: AccountActivity {
|
|||
|
||||
let request = Account.unfollow(account.id)
|
||||
mastodonController.run(request) { (response) in
|
||||
if case let .success(relationship, _) = response {
|
||||
self.mastodonController.cache.add(relationship: relationship)
|
||||
} else {
|
||||
if case .failure(_) = response {
|
||||
// todo: display error message
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||
fatalError()
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
//
|
||||
// AccountActivityItemSource.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 5/14/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import LinkPresentation
|
||||
|
||||
class AccountActivityItemSource: NSObject, UIActivityItemSource {
|
||||
let account: AccountMO
|
||||
|
||||
init(_ account: AccountMO) {
|
||||
self.account = account
|
||||
}
|
||||
|
||||
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
|
||||
return account
|
||||
}
|
||||
|
||||
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
|
||||
return account
|
||||
}
|
||||
|
||||
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
|
||||
let metadata = LPLinkMetadata()
|
||||
metadata.originalURL = account.url
|
||||
metadata.url = account.url
|
||||
metadata.title = "\(account.displayName) (@\(account.username)@\(account.url.host!)"
|
||||
if let data = ImageCache.avatars.get(account.avatar),
|
||||
let image = UIImage(data: data) {
|
||||
metadata.iconProvider = NSItemProvider(object: image)
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
}
|
|
@ -36,10 +36,10 @@ class OpenInSafariActivity: UIActivity {
|
|||
activityDidFinish(true)
|
||||
}
|
||||
|
||||
static func completionHandler(viewController: UIViewController, url: URL) -> UIActivityViewController.CompletionWithItemsHandler {
|
||||
static func completionHandler(navigator: TuskerNavigationDelegate, url: URL) -> UIActivityViewController.CompletionWithItemsHandler {
|
||||
return { (activityType, _, _, _) in
|
||||
if activityType == .openInSafari {
|
||||
viewController.present(SFSafariViewController(url: url), animated: true)
|
||||
navigator.show(SFSafariViewController(url: url))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,10 +26,10 @@ class BookmarkStatusActivity: StatusActivity {
|
|||
override func perform() {
|
||||
guard let status = status else { return }
|
||||
|
||||
let request = Status.bookmark(status)
|
||||
let request = Status.bookmark(status.id)
|
||||
mastodonController.run(request) { (response) in
|
||||
if case let .success(status, _) = response {
|
||||
self.mastodonController.cache.add(status: status)
|
||||
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
||||
} else {
|
||||
// todo: display error message
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
//
|
||||
// MuteConversationActivity.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 5/14/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
class MuteConversationActivity: StatusActivity {
|
||||
override var activityType: UIActivity.ActivityType? {
|
||||
return .muteConversation
|
||||
}
|
||||
|
||||
override var activityTitle: String? {
|
||||
return NSLocalizedString("Mute Conversation", comment: "mute conversation activity title")
|
||||
}
|
||||
|
||||
override var activityImage: UIImage? {
|
||||
return UIImage(systemName: "speaker.slash")
|
||||
}
|
||||
|
||||
override func perform() {
|
||||
guard let status = status else { return }
|
||||
|
||||
let request = Status.muteConversation(status.id)
|
||||
mastodonController.run(request) { (response) in
|
||||
if case let .success(status, _) = response {
|
||||
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
||||
} else {
|
||||
// todo: display error message
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -25,10 +25,10 @@ class PinStatusActivity: StatusActivity {
|
|||
override func perform() {
|
||||
guard let status = status else { return }
|
||||
|
||||
let request = Status.pin(status)
|
||||
let request = Status.pin(status.id)
|
||||
mastodonController.run(request) { (response) in
|
||||
if case let .success(status, _) = response {
|
||||
self.mastodonController.cache.add(status: status)
|
||||
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
||||
} else {
|
||||
// todo: display error message
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
class StatusActivity: MastodonActivity {
|
||||
|
||||
|
@ -15,17 +14,17 @@ class StatusActivity: MastodonActivity {
|
|||
return .action
|
||||
}
|
||||
|
||||
var status: Status?
|
||||
var status: StatusMO?
|
||||
|
||||
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
|
||||
for case is Status in activityItems {
|
||||
for case is StatusMO in activityItems {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override func prepare(withActivityItems activityItems: [Any]) {
|
||||
for case let status as Status in activityItems {
|
||||
for case let status as StatusMO in activityItems {
|
||||
self.status = status
|
||||
return
|
||||
}
|
||||
|
|
|
@ -26,10 +26,10 @@ class UnbookmarkStatusActivity: StatusActivity {
|
|||
override func perform() {
|
||||
guard let status = status else { return }
|
||||
|
||||
let request = Status.unbookmark(status)
|
||||
let request = Status.unbookmark(status.id)
|
||||
mastodonController.run(request) { (response) in
|
||||
if case let .success(status, _) = response {
|
||||
self.mastodonController.cache.add(status: status)
|
||||
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
||||
} else {
|
||||
// todo: display error message
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
//
|
||||
// UnmuteConversationActivity.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 5/14/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
class UnmuteConversationActivity: StatusActivity {
|
||||
override var activityType: UIActivity.ActivityType? {
|
||||
return .unmuteConversation
|
||||
}
|
||||
|
||||
override var activityTitle: String? {
|
||||
return NSLocalizedString("Unmute Conversation", comment: "unmute conversation activity title")
|
||||
}
|
||||
|
||||
override var activityImage: UIImage? {
|
||||
return UIImage(systemName: "speaker")
|
||||
}
|
||||
|
||||
override func perform() {
|
||||
guard let status = status else { return }
|
||||
|
||||
let request = Status.unmuteConversation(status.id)
|
||||
mastodonController.run(request) { (response) in
|
||||
if case let .success(status, _) = response {
|
||||
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
||||
} else {
|
||||
// todo: display error message
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -25,10 +25,10 @@ class UnpinStatusActivity: StatusActivity {
|
|||
override func perform() {
|
||||
guard let status = status else { return }
|
||||
|
||||
let request = Status.unpin(status)
|
||||
let request = Status.unpin(status.id)
|
||||
mastodonController.run(request) { (response) in
|
||||
if case let .success(status, _) = response {
|
||||
self.mastodonController.cache.add(status: status)
|
||||
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
||||
} else {
|
||||
// todo: display error message
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
//
|
||||
// StatusActivityItemSource.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 5/14/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import LinkPresentation
|
||||
import SwiftSoup
|
||||
|
||||
class StatusActivityItemSource: NSObject, UIActivityItemSource {
|
||||
let status: StatusMO
|
||||
|
||||
init(_ status: StatusMO) {
|
||||
self.status = status
|
||||
}
|
||||
|
||||
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
|
||||
return status
|
||||
}
|
||||
|
||||
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
|
||||
return status
|
||||
}
|
||||
|
||||
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
|
||||
let metadata = LPLinkMetadata()
|
||||
metadata.originalURL = status.url!
|
||||
metadata.url = status.url!
|
||||
let doc = try! SwiftSoup.parse(status.content)
|
||||
let content = try! doc.text()
|
||||
metadata.title = "\(status.account.displayName): \"\(content)\""
|
||||
if let data = ImageCache.avatars.get(status.account.avatar),
|
||||
let image = UIImage(data: data) {
|
||||
metadata.iconProvider = NSItemProvider(object: image)
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
}
|
|
@ -22,5 +22,7 @@ extension UIActivity.ActivityType {
|
|||
static let unbookmarkStatus = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).unbookmark_status")
|
||||
static let pinStatus = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).pin_status")
|
||||
static let unpinStatus = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).unpin_status")
|
||||
static let muteConversation = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).mute_conversation")
|
||||
static let unmuteConversation = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).unmute_conversation")
|
||||
|
||||
}
|
||||
|
|
|
@ -7,13 +7,41 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import CrashReporter
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
static private(set) var crashReporter: PLCrashReporter!
|
||||
static var pendingCrashReport: PLCrashReport?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
#if !DEBUG
|
||||
setupCrashReporter()
|
||||
#endif
|
||||
|
||||
AppShortcutItem.createItems(for: application)
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
AudioSessionHelper.disable()
|
||||
AudioSessionHelper.setDefault()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func setupCrashReporter() {
|
||||
let config = PLCrashReporterConfig(signalHandlerType: .BSD, symbolicationStrategy: .all)
|
||||
AppDelegate.crashReporter = PLCrashReporter(configuration: config)
|
||||
|
||||
if AppDelegate.crashReporter.hasPendingCrashReport() {
|
||||
let data = try! AppDelegate.crashReporter.loadPendingCrashReportDataAndReturnError()
|
||||
AppDelegate.crashReporter.purgePendingCrashReport()
|
||||
let report = try! PLCrashReport(data: data)
|
||||
|
||||
AppDelegate.pendingCrashReport = report
|
||||
}
|
||||
|
||||
AppDelegate.crashReporter.enable()
|
||||
}
|
||||
}
|
||||
|
|
After Width: | Height: | Size: 82 KiB |
After Width: | Height: | Size: 580 B |
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 917 B |
After Width: | Height: | Size: 2.0 KiB |
After Width: | Height: | Size: 2.0 KiB |
After Width: | Height: | Size: 3.3 KiB |
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 3.0 KiB |
After Width: | Height: | Size: 3.0 KiB |
After Width: | Height: | Size: 4.9 KiB |
After Width: | Height: | Size: 4.9 KiB |
After Width: | Height: | Size: 8.2 KiB |
After Width: | Height: | Size: 2.8 KiB |
After Width: | Height: | Size: 6.6 KiB |
After Width: | Height: | Size: 7.3 KiB |
|
@ -1,98 +1,116 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "20x20@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"size" : "20x20",
|
||||
"scale" : "2x"
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "20x20@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"size" : "20x20",
|
||||
"scale" : "3x"
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "29x29@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"size" : "29x29",
|
||||
"scale" : "2x"
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "29x29@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"size" : "29x29",
|
||||
"scale" : "3x"
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "40x40@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"size" : "40x40",
|
||||
"scale" : "2x"
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "40x40@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"size" : "40x40",
|
||||
"scale" : "3x"
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "60x60@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"size" : "60x60",
|
||||
"scale" : "2x"
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "60x60@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"size" : "60x60",
|
||||
"scale" : "3x"
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "20x20@1x.png",
|
||||
"idiom" : "ipad",
|
||||
"size" : "20x20",
|
||||
"scale" : "1x"
|
||||
"scale" : "1x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "20x20@2x-1.png",
|
||||
"idiom" : "ipad",
|
||||
"size" : "20x20",
|
||||
"scale" : "2x"
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "29x29@1x.png",
|
||||
"idiom" : "ipad",
|
||||
"size" : "29x29",
|
||||
"scale" : "1x"
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "29x29@2x-1.png",
|
||||
"idiom" : "ipad",
|
||||
"size" : "29x29",
|
||||
"scale" : "2x"
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "40x40@1x.png",
|
||||
"idiom" : "ipad",
|
||||
"size" : "40x40",
|
||||
"scale" : "1x"
|
||||
"scale" : "1x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "40x40@2x-1.png",
|
||||
"idiom" : "ipad",
|
||||
"size" : "40x40",
|
||||
"scale" : "2x"
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "76x76@1x.png",
|
||||
"idiom" : "ipad",
|
||||
"size" : "76x76",
|
||||
"scale" : "1x"
|
||||
"scale" : "1x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "76x76@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"size" : "76x76",
|
||||
"scale" : "2x"
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "83.5x83.5@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"size" : "83.5x83.5",
|
||||
"scale" : "2x"
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"filename" : "1024x1024@1x.png",
|
||||
"idiom" : "ios-marketing",
|
||||
"size" : "1024x1024",
|
||||
"scale" : "1x"
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
//
|
||||
// AudioSessionHelper.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 6/21/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
struct AudioSessionHelper {
|
||||
static func enable() {
|
||||
try? AVAudioSession.sharedInstance().setActive(true, options: [])
|
||||
}
|
||||
|
||||
static func disable() {
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
static func setDefault() {
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback, options: .mixWithOthers)
|
||||
}
|
||||
|
||||
static func setVideoPlayback() {
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback, options: [])
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// CachedDictionary.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 5/6/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class CachedDictionary<Value> {
|
||||
private let name: String
|
||||
private var dict = [String: Value]()
|
||||
private let queue: DispatchQueue
|
||||
|
||||
init(name: String) {
|
||||
self.name = name
|
||||
self.queue = DispatchQueue(label: "CachedDictionary (\(name)) Coordinator", attributes: .concurrent)
|
||||
}
|
||||
|
||||
subscript(key: String) -> Value? {
|
||||
get {
|
||||
var result: Value? = nil
|
||||
queue.sync {
|
||||
result = dict[key]
|
||||
}
|
||||
return result
|
||||
}
|
||||
set(value) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.dict[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -47,4 +47,15 @@ enum Cache<T> {
|
|||
try hybrid.setObject(object, forKey: key, expiry: expiry)
|
||||
}
|
||||
}
|
||||
|
||||
func removeAll() throws {
|
||||
switch self {
|
||||
case let .memory(memory):
|
||||
memory.removeAll()
|
||||
case let .disk(disk):
|
||||
try disk.removeAll()
|
||||
case let .hybrid(hybrid):
|
||||
try hybrid.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,9 +16,9 @@ class ImageCache {
|
|||
static let attachments = ImageCache(name: "Attachments", memoryExpiry: .seconds(60 * 2))
|
||||
static let emojis = ImageCache(name: "Emojis", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60))
|
||||
|
||||
let cache: Cache<Data>
|
||||
private let cache: Cache<Data>
|
||||
|
||||
var requests = [URL: RequestGroup]()
|
||||
private var groups = [URL: RequestGroup]()
|
||||
|
||||
init(name: String, memoryExpiry expiry: Expiry) {
|
||||
let storage = MemoryStorage<Data>(config: MemoryConfig(expiry: expiry))
|
||||
|
@ -43,14 +43,18 @@ class ImageCache {
|
|||
completion?(data)
|
||||
return nil
|
||||
} else {
|
||||
if let completion = completion, let group = requests[url] {
|
||||
if let completion = completion, let group = groups[url] {
|
||||
return group.addCallback(completion)
|
||||
} else {
|
||||
let group = RequestGroup(url: url)
|
||||
let request = group.addCallback(completion)
|
||||
group.run { (data) in
|
||||
let group = RequestGroup(url: url) { (data) in
|
||||
if let data = data {
|
||||
try? self.cache.setObject(data, forKey: key)
|
||||
}
|
||||
self.groups.removeValue(forKey: url)
|
||||
}
|
||||
groups[url] = group
|
||||
let request = group.addCallback(completion)
|
||||
group.run()
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
@ -61,29 +65,34 @@ class ImageCache {
|
|||
}
|
||||
|
||||
func cancelWithoutCallback(_ url: URL) {
|
||||
requests[url]?.cancelWithoutCallback()
|
||||
groups[url]?.cancelWithoutCallback()
|
||||
}
|
||||
|
||||
class RequestGroup {
|
||||
func reset() throws {
|
||||
try cache.removeAll()
|
||||
}
|
||||
|
||||
private class RequestGroup {
|
||||
let url: URL
|
||||
private let onFinished: (Data?) -> Void
|
||||
private var task: URLSessionDataTask?
|
||||
private var requests = [Request]()
|
||||
|
||||
init(url: URL) {
|
||||
init(url: URL, onFinished: @escaping (Data?) -> Void) {
|
||||
self.url = url
|
||||
self.onFinished = onFinished
|
||||
}
|
||||
|
||||
deinit {
|
||||
task?.cancel()
|
||||
}
|
||||
|
||||
func run(cache: @escaping (Data) -> Void) {
|
||||
func run() {
|
||||
task = URLSession.shared.dataTask(with: url, completionHandler: { (data, response, error) in
|
||||
guard error == nil, let data = data else {
|
||||
self.complete(with: nil)
|
||||
return
|
||||
}
|
||||
cache(data)
|
||||
self.complete(with: data)
|
||||
})
|
||||
task!.resume()
|
||||
|
@ -123,11 +132,12 @@ class ImageCache {
|
|||
callback(data)
|
||||
}
|
||||
}
|
||||
self.onFinished(data)
|
||||
}
|
||||
}
|
||||
|
||||
class Request {
|
||||
weak var group: RequestGroup?
|
||||
private weak var group: RequestGroup?
|
||||
private(set) var callback: ((Data?) -> Void)?
|
||||
private(set) var cancelled: Bool = false
|
||||
|
||||
|
|
|
@ -30,20 +30,30 @@ class MastodonController {
|
|||
}
|
||||
}
|
||||
|
||||
private(set) lazy var cache = MastodonCache(mastodonController: self)
|
||||
static func resetAll() {
|
||||
all = [:]
|
||||
}
|
||||
|
||||
private let transient: Bool
|
||||
private(set) lazy var persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient)
|
||||
|
||||
let instanceURL: URL
|
||||
private(set) var accountInfo: LocalData.UserAccountInfo?
|
||||
var accountInfo: LocalData.UserAccountInfo?
|
||||
|
||||
let client: Client!
|
||||
|
||||
var account: Account!
|
||||
var instance: Instance!
|
||||
|
||||
init(instanceURL: URL) {
|
||||
var loggedIn: Bool {
|
||||
accountInfo != nil
|
||||
}
|
||||
|
||||
init(instanceURL: URL, transient: Bool = false) {
|
||||
self.instanceURL = instanceURL
|
||||
self.accountInfo = nil
|
||||
self.client = Client(baseURL: instanceURL)
|
||||
self.transient = transient
|
||||
}
|
||||
|
||||
func run<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) {
|
||||
|
@ -74,26 +84,47 @@ class MastodonController {
|
|||
}
|
||||
}
|
||||
|
||||
func getOwnAccount(completion: ((Account) -> Void)? = nil) {
|
||||
func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) {
|
||||
if account != nil {
|
||||
completion?(account)
|
||||
completion?(.success(account))
|
||||
} else {
|
||||
let request = Client.getSelfAccount()
|
||||
run(request) { response in
|
||||
guard case let .success(account, _) = response else { fatalError() }
|
||||
switch response {
|
||||
case let .failure(error):
|
||||
completion?(.failure(error))
|
||||
|
||||
case let .success(account, _):
|
||||
self.account = account
|
||||
self.cache.add(account: account)
|
||||
completion?(account)
|
||||
self.persistentContainer.backgroundContext.perform {
|
||||
if let accountMO = self.persistentContainer.account(for: account.id, in: self.persistentContainer.backgroundContext) {
|
||||
accountMO.updateFrom(apiAccount: account, container: self.persistentContainer)
|
||||
} else {
|
||||
// the first time the user's account is added to the store,
|
||||
// increment its reference count so that it's never removed
|
||||
self.persistentContainer.addOrUpdate(account: account, incrementReferenceCount: true)
|
||||
}
|
||||
completion?(.success(account))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getOwnInstance() {
|
||||
func getOwnInstance(completion: ((Instance) -> Void)? = nil) {
|
||||
if let instance = self.instance {
|
||||
completion?(instance)
|
||||
} else {
|
||||
let request = Client.getInstance()
|
||||
run(request) { (response) in
|
||||
guard case let .success(instance, _) = response else { fatalError() }
|
||||
self.instance = instance
|
||||
completion?(instance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ObservableObject so that SwiftUI views can receive it through @EnvironmentObject
|
||||
extension MastodonController: ObservableObject {}
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
//
|
||||
// AccountMO.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 4/11/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Pachyderm
|
||||
|
||||
@objc(AccountMO)
|
||||
public final class AccountMO: NSManagedObject, AccountProtocol {
|
||||
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<AccountMO> {
|
||||
return NSFetchRequest<AccountMO>(entityName: "Account")
|
||||
}
|
||||
|
||||
@NSManaged public var acct: String
|
||||
@NSManaged public var avatar: URL
|
||||
@NSManaged public var botCD: Bool
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var displayName: String
|
||||
@NSManaged private var emojisData: Data?
|
||||
@NSManaged private var fieldsData: Data?
|
||||
@NSManaged public var followersCount: Int
|
||||
@NSManaged public var followingCount: Int
|
||||
@NSManaged public var header: URL
|
||||
@NSManaged public var id: String
|
||||
@NSManaged public var locked: Bool
|
||||
@NSManaged public var movedCD: Bool
|
||||
@NSManaged public var note: String
|
||||
@NSManaged public var referenceCount: Int
|
||||
@NSManaged public var statusesCount: Int
|
||||
@NSManaged public var url: URL
|
||||
@NSManaged public var username: String
|
||||
@NSManaged public var movedTo: AccountMO?
|
||||
|
||||
@LazilyDecoding(arrayFrom: \AccountMO.emojisData)
|
||||
public var emojis: [Emoji]
|
||||
|
||||
@LazilyDecoding(arrayFrom: \AccountMO.fieldsData)
|
||||
public var fields: [Pachyderm.Account.Field]
|
||||
|
||||
public var bot: Bool? { botCD }
|
||||
public var moved: Bool? { movedCD }
|
||||
|
||||
func incrementReferenceCount() {
|
||||
referenceCount += 1
|
||||
}
|
||||
|
||||
func decrementReferenceCount() {
|
||||
referenceCount -= 1
|
||||
if referenceCount <= 0 {
|
||||
managedObjectContext!.delete(self)
|
||||
}
|
||||
}
|
||||
|
||||
public override func prepareForDeletion() {
|
||||
super.prepareForDeletion()
|
||||
movedTo?.decrementReferenceCount()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension AccountMO {
|
||||
convenience init(apiAccount account: Pachyderm.Account, container: MastodonCachePersistentStore, context: NSManagedObjectContext) {
|
||||
self.init(context: context)
|
||||
self.updateFrom(apiAccount: account, container: container)
|
||||
|
||||
movedTo?.incrementReferenceCount()
|
||||
}
|
||||
|
||||
func updateFrom(apiAccount account: Pachyderm.Account, container: MastodonCachePersistentStore) {
|
||||
guard let context = managedObjectContext else {
|
||||
// we've been deleted, don't bother updating
|
||||
return
|
||||
}
|
||||
|
||||
self.acct = account.acct
|
||||
self.avatar = account.avatarStatic // we don't animate avatars
|
||||
self.botCD = account.bot ?? false
|
||||
self.createdAt = account.createdAt
|
||||
self.displayName = account.displayName
|
||||
self.emojis = account.emojis
|
||||
self.fields = account.fields
|
||||
self.followersCount = account.followersCount
|
||||
self.followingCount = account.followingCount
|
||||
self.header = account.headerStatic // we don't animate headers
|
||||
self.id = account.id
|
||||
self.locked = account.locked
|
||||
self.movedCD = account.moved ?? false
|
||||
self.note = account.note
|
||||
self.statusesCount = account.statusesCount
|
||||
self.url = account.url
|
||||
self.username = account.username
|
||||
if let movedTo = account.movedTo {
|
||||
self.movedTo = container.account(for: movedTo.id, in: context) ?? AccountMO(apiAccount: movedTo, container: container, context: context)
|
||||
} else {
|
||||
self.movedTo = nil
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,190 @@
|
|||
//
|
||||
// MastodonCachePersistentStore.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 4/11/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Pachyderm
|
||||
import Combine
|
||||
|
||||
class MastodonCachePersistentStore: NSPersistentContainer {
|
||||
|
||||
private static let managedObjectModel: NSManagedObjectModel = {
|
||||
let url = Bundle.main.url(forResource: "Tusker", withExtension: "momd")!
|
||||
return NSManagedObjectModel(contentsOf: url)!
|
||||
}()
|
||||
|
||||
private(set) lazy var backgroundContext: NSManagedObjectContext = {
|
||||
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
||||
context.parent = self.viewContext
|
||||
return context
|
||||
}()
|
||||
|
||||
let statusSubject = PassthroughSubject<String, Never>()
|
||||
let accountSubject = PassthroughSubject<String, Never>()
|
||||
|
||||
init(for accountInfo: LocalData.UserAccountInfo?, transient: Bool = false) {
|
||||
if transient {
|
||||
super.init(name: "transient_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
|
||||
|
||||
let storeDescription = NSPersistentStoreDescription()
|
||||
storeDescription.type = NSInMemoryStoreType
|
||||
persistentStoreDescriptions = [storeDescription]
|
||||
} else {
|
||||
super.init(name: "\(accountInfo!.id)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
|
||||
}
|
||||
|
||||
loadPersistentStores { (description, error) in
|
||||
if let error = error {
|
||||
fatalError("Unable to load persistent store: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func status(for id: String, in context: NSManagedObjectContext? = nil) -> StatusMO? {
|
||||
let context = context ?? viewContext
|
||||
let request: NSFetchRequest<StatusMO> = StatusMO.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id = %@", id)
|
||||
request.fetchLimit = 1
|
||||
if let result = try? context.fetch(request), let status = result.first {
|
||||
return status
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func upsert(status: Status, incrementReferenceCount: Bool) -> StatusMO {
|
||||
if let statusMO = self.status(for: status.id, in: self.backgroundContext) {
|
||||
statusMO.updateFrom(apiStatus: status, container: self)
|
||||
if incrementReferenceCount {
|
||||
statusMO.incrementReferenceCount()
|
||||
}
|
||||
return statusMO
|
||||
} else {
|
||||
let statusMO = StatusMO(apiStatus: status, container: self, context: self.backgroundContext)
|
||||
if incrementReferenceCount {
|
||||
statusMO.incrementReferenceCount()
|
||||
}
|
||||
return statusMO
|
||||
}
|
||||
}
|
||||
|
||||
func addOrUpdate(status: Status, incrementReferenceCount: Bool, completion: ((StatusMO) -> Void)? = nil) {
|
||||
backgroundContext.perform {
|
||||
let statusMO = self.upsert(status: status, incrementReferenceCount: incrementReferenceCount)
|
||||
if self.backgroundContext.hasChanges {
|
||||
try! self.backgroundContext.save()
|
||||
}
|
||||
completion?(statusMO)
|
||||
self.statusSubject.send(status.id)
|
||||
}
|
||||
}
|
||||
|
||||
func addAll(statuses: [Status], completion: (() -> Void)? = nil) {
|
||||
backgroundContext.perform {
|
||||
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true) }
|
||||
if self.backgroundContext.hasChanges {
|
||||
try! self.backgroundContext.save()
|
||||
}
|
||||
statuses.forEach { self.statusSubject.send($0.id) }
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
func account(for id: String, in context: NSManagedObjectContext? = nil) -> AccountMO? {
|
||||
let context = context ?? viewContext
|
||||
let request: NSFetchRequest<AccountMO> = AccountMO.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id = %@", id)
|
||||
request.fetchLimit = 1
|
||||
if let result = try? context.fetch(request), let account = result.first {
|
||||
return account
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func upsert(account: Account, incrementReferenceCount: Bool) -> AccountMO {
|
||||
if let accountMO = self.account(for: account.id, in: self.backgroundContext) {
|
||||
accountMO.updateFrom(apiAccount: account, container: self)
|
||||
if incrementReferenceCount {
|
||||
accountMO.incrementReferenceCount()
|
||||
}
|
||||
return accountMO
|
||||
} else {
|
||||
let accountMO = AccountMO(apiAccount: account, container: self, context: self.backgroundContext)
|
||||
if incrementReferenceCount {
|
||||
accountMO.incrementReferenceCount()
|
||||
}
|
||||
return accountMO
|
||||
}
|
||||
}
|
||||
|
||||
func addOrUpdate(account: Account, incrementReferenceCount: Bool, completion: ((AccountMO) -> Void)? = nil) {
|
||||
backgroundContext.perform {
|
||||
let accountMO = self.upsert(account: account, incrementReferenceCount: incrementReferenceCount)
|
||||
if self.backgroundContext.hasChanges {
|
||||
try! self.backgroundContext.save()
|
||||
}
|
||||
completion?(accountMO)
|
||||
self.accountSubject.send(account.id)
|
||||
}
|
||||
}
|
||||
|
||||
func addAll(accounts: [Account], completion: (() -> Void)? = nil) {
|
||||
backgroundContext.perform {
|
||||
accounts.forEach { self.upsert(account: $0, incrementReferenceCount: true) }
|
||||
if self.backgroundContext.hasChanges {
|
||||
try! self.backgroundContext.save()
|
||||
}
|
||||
completion?()
|
||||
accounts.forEach { self.accountSubject.send($0.id) }
|
||||
}
|
||||
}
|
||||
|
||||
func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) {
|
||||
backgroundContext.perform {
|
||||
let statuses = notifications.compactMap { $0.status }
|
||||
// filter out mentions, otherwise we would double increment the reference count of those accounts
|
||||
// since the status has the same account as the notification
|
||||
let accounts = notifications.filter { $0.kind != .mention }.map { $0.account }
|
||||
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true) }
|
||||
accounts.forEach { self.upsert(account: $0, incrementReferenceCount: true) }
|
||||
if self.backgroundContext.hasChanges {
|
||||
try! self.backgroundContext.save()
|
||||
}
|
||||
completion?()
|
||||
statuses.forEach { self.statusSubject.send($0.id) }
|
||||
accounts.forEach { self.accountSubject.send($0.id) }
|
||||
}
|
||||
}
|
||||
|
||||
func performBatchUpdates(_ block: @escaping (_ context: NSManagedObjectContext, _ addAccounts: ([Account]) -> Void, _ addStatuses: ([Status]) -> Void) -> Void, completion: (() -> Void)? = nil) {
|
||||
backgroundContext.perform {
|
||||
var updatedAccounts = [String]()
|
||||
var updatedStatuses = [String]()
|
||||
|
||||
block(self.backgroundContext, { (accounts) in
|
||||
accounts.forEach { self.upsert(account: $0, incrementReferenceCount: true) }
|
||||
updatedAccounts.append(contentsOf: accounts.map { $0.id })
|
||||
}, { (statuses) in
|
||||
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true) }
|
||||
updatedStatuses.append(contentsOf: statuses.map { $0.id })
|
||||
})
|
||||
|
||||
updatedAccounts.forEach(self.accountSubject.send)
|
||||
updatedStatuses.forEach(self.statusSubject.send)
|
||||
|
||||
if self.backgroundContext.hasChanges {
|
||||
try! self.backgroundContext.save()
|
||||
}
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,144 @@
|
|||
//
|
||||
// StatusMO.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 4/11/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Pachyderm
|
||||
|
||||
@objc(StatusMO)
|
||||
public final class StatusMO: NSManagedObject, StatusProtocol {
|
||||
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<StatusMO> {
|
||||
return NSFetchRequest<StatusMO>(entityName: "Status")
|
||||
}
|
||||
|
||||
@NSManaged public var applicationName: String?
|
||||
@NSManaged private var attachmentsData: Data?
|
||||
@NSManaged private var bookmarkedInternal: Bool
|
||||
@NSManaged public var content: String
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged private var emojisData: Data?
|
||||
@NSManaged public var favourited: Bool
|
||||
@NSManaged public var favouritesCount: Int
|
||||
@NSManaged private var hashtagsData: Data?
|
||||
@NSManaged public var id: String
|
||||
@NSManaged public var inReplyToAccountID: String?
|
||||
@NSManaged public var inReplyToID: String?
|
||||
@NSManaged private var mentionsData: Data?
|
||||
@NSManaged public var muted: Bool
|
||||
@NSManaged private var pinnedInternal: Bool
|
||||
@NSManaged public var reblogged: Bool
|
||||
@NSManaged public var reblogsCount: Int
|
||||
@NSManaged public var referenceCount: Int
|
||||
@NSManaged public var sensitive: Bool
|
||||
@NSManaged public var spoilerText: String
|
||||
@NSManaged public var uri: String // todo: are both uri and url necessary?
|
||||
@NSManaged public var url: URL?
|
||||
@NSManaged private var visibilityString: String
|
||||
@NSManaged public var account: AccountMO
|
||||
@NSManaged public var reblog: StatusMO?
|
||||
|
||||
@LazilyDecoding(arrayFrom: \StatusMO.attachmentsData)
|
||||
public var attachments: [Attachment]
|
||||
|
||||
@LazilyDecoding(arrayFrom: \StatusMO.emojisData)
|
||||
public var emojis: [Emoji]
|
||||
|
||||
@LazilyDecoding(arrayFrom: \StatusMO.hashtagsData)
|
||||
public var hashtags: [Hashtag]
|
||||
|
||||
@LazilyDecoding(arrayFrom: \StatusMO.mentionsData)
|
||||
public var mentions: [Mention]
|
||||
|
||||
public var pinned: Bool? { pinnedInternal }
|
||||
public var bookmarked: Bool? { bookmarkedInternal }
|
||||
|
||||
public var visibility: Pachyderm.Status.Visibility {
|
||||
get {
|
||||
Pachyderm.Status.Visibility(rawValue: visibilityString) ?? .public
|
||||
}
|
||||
set {
|
||||
visibilityString = newValue.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
func incrementReferenceCount() {
|
||||
referenceCount += 1
|
||||
}
|
||||
|
||||
func decrementReferenceCount() {
|
||||
referenceCount -= 1
|
||||
if referenceCount <= 0 {
|
||||
managedObjectContext!.delete(self)
|
||||
}
|
||||
}
|
||||
|
||||
public override func prepareForDeletion() {
|
||||
super.prepareForDeletion()
|
||||
reblog?.decrementReferenceCount()
|
||||
account.decrementReferenceCount()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension StatusMO {
|
||||
convenience init(apiStatus status: Pachyderm.Status, container: MastodonCachePersistentStore, context: NSManagedObjectContext) {
|
||||
self.init(context: context)
|
||||
self.updateFrom(apiStatus: status, container: container)
|
||||
|
||||
reblog?.incrementReferenceCount()
|
||||
account.incrementReferenceCount()
|
||||
}
|
||||
|
||||
func updateFrom(apiStatus status: Pachyderm.Status, container: MastodonCachePersistentStore) {
|
||||
guard let context = managedObjectContext else {
|
||||
// we have been deleted, don't bother updating
|
||||
return
|
||||
}
|
||||
|
||||
self.applicationName = status.application?.name
|
||||
self.attachments = status.attachments
|
||||
self.bookmarkedInternal = status.bookmarked ?? false
|
||||
self.content = status.content
|
||||
self.createdAt = status.createdAt
|
||||
self.emojis = status.emojis
|
||||
self.favourited = status.favourited ?? false
|
||||
self.favouritesCount = status.favouritesCount
|
||||
self.hashtags = status.hashtags
|
||||
self.inReplyToAccountID = status.inReplyToAccountID
|
||||
self.inReplyToID = status.inReplyToID
|
||||
self.id = status.id
|
||||
self.mentions = status.mentions
|
||||
self.muted = status.muted ?? false
|
||||
self.pinnedInternal = status.pinned ?? false
|
||||
self.reblogged = status.reblogged ?? false
|
||||
self.reblogsCount = status.reblogsCount
|
||||
self.sensitive = status.sensitive
|
||||
self.spoilerText = status.spoilerText
|
||||
self.uri = status.uri
|
||||
self.url = status.url
|
||||
self.visibility = status.visibility
|
||||
if let existing = container.account(for: status.account.id, in: context) {
|
||||
existing.updateFrom(apiAccount: status.account, container: container)
|
||||
self.account = existing
|
||||
} else {
|
||||
self.account = AccountMO(apiAccount: status.account, container: container, context: context)
|
||||
}
|
||||
if let reblog = status.reblog {
|
||||
if let existing = container.status(for: reblog.id, in: context) {
|
||||
existing.updateFrom(apiStatus: reblog, container: container)
|
||||
self.reblog = existing
|
||||
} else {
|
||||
self.reblog = StatusMO(apiStatus: reblog, container: container, context: context)
|
||||
}
|
||||
} else {
|
||||
self.reblog = nil
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="16119" systemVersion="19D76" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Account" representedClassName="AccountMO" syncable="YES">
|
||||
<attribute name="acct" attributeType="String"/>
|
||||
<attribute name="avatar" attributeType="URI"/>
|
||||
<attribute name="botCD" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="displayName" attributeType="String"/>
|
||||
<attribute name="emojisData" attributeType="Binary"/>
|
||||
<attribute name="fieldsData" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="followersCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="followingCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="header" attributeType="URI"/>
|
||||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="locked" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="movedCD" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="note" attributeType="String"/>
|
||||
<attribute name="referenceCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="url" attributeType="URI"/>
|
||||
<attribute name="username" attributeType="String"/>
|
||||
<relationship name="movedTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="Status" representedClassName="StatusMO" syncable="YES">
|
||||
<attribute name="applicationName" optional="YES" attributeType="String"/>
|
||||
<attribute name="attachmentsData" attributeType="Binary"/>
|
||||
<attribute name="bookmarkedInternal" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="content" attributeType="String"/>
|
||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="emojisData" attributeType="Binary" customClassName="[Data]"/>
|
||||
<attribute name="favourited" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="favouritesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="hashtagsData" attributeType="Binary"/>
|
||||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="inReplyToAccountID" optional="YES" attributeType="String"/>
|
||||
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
|
||||
<attribute name="mentionsData" attributeType="Binary"/>
|
||||
<attribute name="muted" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="pinnedInternal" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="reblogged" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="reblogsCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="referenceCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="sensitive" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="spoilerText" attributeType="String"/>
|
||||
<attribute name="uri" attributeType="String"/>
|
||||
<attribute name="url" optional="YES" attributeType="URI"/>
|
||||
<attribute name="visibilityString" attributeType="String"/>
|
||||
<relationship name="account" maxCount="1" deletionRule="Nullify" destinationEntity="Account"/>
|
||||
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<elements>
|
||||
<element name="Account" positionX="169.21875" positionY="78.9609375" width="128" height="328"/>
|
||||
<element name="Status" positionX="-63" positionY="-18" width="128" height="418"/>
|
||||
</elements>
|
||||
</model>
|
|
@ -1,92 +0,0 @@
|
|||
//
|
||||
// DraftsManager.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 10/22/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class DraftsManager: Codable {
|
||||
|
||||
private(set) static var shared: DraftsManager = load()
|
||||
|
||||
private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
private static var archiveURL = DraftsManager.documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
|
||||
|
||||
static func save() {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let encoder = PropertyListEncoder()
|
||||
let data = try? encoder.encode(shared)
|
||||
try? data?.write(to: archiveURL, options: .noFileProtection)
|
||||
}
|
||||
}
|
||||
|
||||
static func load() -> DraftsManager {
|
||||
let decoder = PropertyListDecoder()
|
||||
if let data = try? Data(contentsOf: archiveURL),
|
||||
let draftsManager = try? decoder.decode(DraftsManager.self, from: data) {
|
||||
return draftsManager
|
||||
}
|
||||
return DraftsManager()
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
var drafts: [Draft] = []
|
||||
var sorted: [Draft] {
|
||||
return drafts.sorted(by: { $0.lastModified > $1.lastModified })
|
||||
}
|
||||
|
||||
func create(accountID: String, text: String, contentWarning: String?, inReplyToID: String?, attachments: [DraftAttachment]) -> Draft {
|
||||
let draft = Draft(accountID: accountID, text: text, contentWarning: contentWarning, inReplyToID: inReplyToID, attachments: attachments)
|
||||
drafts.append(draft)
|
||||
return draft
|
||||
}
|
||||
|
||||
func remove(_ draft: Draft) {
|
||||
let index = drafts.firstIndex(of: draft)!
|
||||
drafts.remove(at: index)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension DraftsManager {
|
||||
class Draft: Codable, Equatable {
|
||||
let id: UUID
|
||||
private(set) var accountID: String
|
||||
private(set) var text: String
|
||||
private(set) var contentWarning: String?
|
||||
private(set) var attachments: [DraftAttachment]
|
||||
private(set) var inReplyToID: String?
|
||||
private(set) var lastModified: Date
|
||||
|
||||
init(accountID: String, text: String, contentWarning: String?, inReplyToID: String?, attachments: [DraftAttachment], lastModified: Date = Date()) {
|
||||
self.id = UUID()
|
||||
self.accountID = accountID
|
||||
self.text = text
|
||||
self.contentWarning = contentWarning
|
||||
self.inReplyToID = inReplyToID
|
||||
self.attachments = attachments
|
||||
self.lastModified = lastModified
|
||||
}
|
||||
|
||||
func update(accountID: String, text: String, contentWarning: String?, attachments: [DraftAttachment]) {
|
||||
self.accountID = accountID
|
||||
self.text = text
|
||||
self.contentWarning = contentWarning
|
||||
self.lastModified = Date()
|
||||
self.attachments = attachments
|
||||
}
|
||||
|
||||
static func ==(lhs: Draft, rhs: Draft) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
struct DraftAttachment: Codable {
|
||||
let attachment: CompositionAttachment
|
||||
let description: String
|
||||
}
|
||||
}
|
|
@ -9,23 +9,29 @@
|
|||
import Foundation
|
||||
import Pachyderm
|
||||
|
||||
extension Account {
|
||||
extension AccountMO {
|
||||
|
||||
var realDisplayName: String {
|
||||
var displayOrUserName: String {
|
||||
if displayName.isEmpty {
|
||||
return username
|
||||
} else if Preferences.shared.hideCustomEmojiInUsernames {
|
||||
return stripCustomEmoji(from: displayName)
|
||||
} else {
|
||||
return displayName
|
||||
}
|
||||
}
|
||||
|
||||
var displayNameWithoutCustomEmoji: String {
|
||||
if displayName.isEmpty {
|
||||
return username
|
||||
} else {
|
||||
return stripCustomEmoji(from: displayName)
|
||||
}
|
||||
}
|
||||
|
||||
private static let customEmojiRegex = try! NSRegularExpression(pattern: ":[a-zA-Z0-9_]+:", options: [])
|
||||
|
||||
private func stripCustomEmoji(from string: String) -> String {
|
||||
let range = NSRange(location: 0, length: string.utf16.count)
|
||||
return Account.customEmojiRegex.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
|
||||
return AccountMO.customEmojiRegex.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -10,31 +10,6 @@ import Foundation
|
|||
|
||||
extension Date {
|
||||
|
||||
// var timeAgo: String {
|
||||
// let calendar = NSCalendar.current
|
||||
// let unitFlags = Set<Calendar.Component>([.second, .minute, .hour, .day, .weekOfYear, .month, .year])
|
||||
//
|
||||
// let components = calendar.dateComponents(unitFlags, from: self, to: Date())
|
||||
//
|
||||
// if components.year! >= 1 {
|
||||
// return "\(components.year!)y"
|
||||
// } else if components.month! >= 1 {
|
||||
// return "\(components.month!)mo"
|
||||
// } else if components.weekOfYear! >= 1 {
|
||||
// return "\(components.weekOfYear!)w"
|
||||
// } else if components.day! >= 1 {
|
||||
// return "\(components.day!)d"
|
||||
// } else if components.hour! >= 1 {
|
||||
// return "\(components.hour!)h"
|
||||
// } else if components.minute! >= 1 {
|
||||
// return "\(components.minute!)m"
|
||||
// } else if components.second! >= 3 {
|
||||
// return "\(components.second!)s"
|
||||
// } else {
|
||||
// return "Now"
|
||||
// }
|
||||
// }
|
||||
|
||||
func timeAgo() -> (Int, Calendar.Component) {
|
||||
let calendar = NSCalendar.current
|
||||
let unitFlags = Set<Calendar.Component>([.second, .minute, .hour, .day, .weekOfYear, .month, .year])
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
//
|
||||
// NSTextAttachment+Emoji.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 3/1/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension NSTextAttachment {
|
||||
// Based on https://github.com/ReticentJohn/Amaroq/blob/7c5b7088eb9fd1611dcb0f47d43bf8df093e142c/DireFloof/InlineImageHelpers.m
|
||||
convenience init(emojiImage image: UIImage, in font: UIFont, with textColor: UIColor = .label) {
|
||||
let adjustedCapHeight = font.capHeight - 1
|
||||
var imageSizeMatchingFontSize = CGSize(width: image.size.width * (adjustedCapHeight / image.size.height), height: adjustedCapHeight)
|
||||
|
||||
let defaultScale: CGFloat = 1.4
|
||||
imageSizeMatchingFontSize = CGSize(width: imageSizeMatchingFontSize.width * defaultScale, height: imageSizeMatchingFontSize.height * defaultScale)
|
||||
|
||||
UIGraphicsBeginImageContextWithOptions(imageSizeMatchingFontSize, false, 0.0)
|
||||
textColor.set()
|
||||
image.draw(in: CGRect(origin: .zero, size: imageSizeMatchingFontSize))
|
||||
let attachmentImage = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext()
|
||||
|
||||
self.init()
|
||||
self.image = attachmentImage
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
//
|
||||
// PKDrawing+Render.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 5/9/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import PencilKit
|
||||
|
||||
extension PKDrawing {
|
||||
|
||||
func imageInLightMode(from rect: CGRect, scale: CGFloat = UIScreen.main.scale) -> UIImage {
|
||||
let lightTraitCollection = UITraitCollection(userInterfaceStyle: .light)
|
||||
var drawingImage: UIImage!
|
||||
lightTraitCollection.performAsCurrent {
|
||||
drawingImage = self.image(from: rect, scale: scale)
|
||||
}
|
||||
|
||||
let imageRect = CGRect(origin: .zero, size: rect.size)
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.opaque = false
|
||||
format.scale = scale
|
||||
let renderer = UIGraphicsImageRenderer(size: rect.size, format: format)
|
||||
return renderer.image { (context) in
|
||||
UIColor.white.setFill()
|
||||
context.fill(imageRect)
|
||||
drawingImage.draw(in: imageRect)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
//
|
||||
// StatusStateResolver.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 9/15/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Pachyderm
|
||||
|
||||
extension StatusState {
|
||||
|
||||
func resolveFor(status: StatusMO, text: String?) {
|
||||
let longEnoughToCollapse: Bool
|
||||
if Preferences.shared.collapseLongPosts,
|
||||
let text = text,
|
||||
text.count > 500 {
|
||||
longEnoughToCollapse = true
|
||||
} else {
|
||||
longEnoughToCollapse = false
|
||||
}
|
||||
|
||||
let contentWarningCollapsible = !status.spoilerText.isEmpty
|
||||
|
||||
self.collapsible = contentWarningCollapsible || longEnoughToCollapse
|
||||
self.collapsed = longEnoughToCollapse || (!Preferences.shared.expandAllContentWarnings && contentWarningCollapsible)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
//
|
||||
// UIAccessibility.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 9/12/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIAccessibility {
|
||||
|
||||
static var prefersCrossFadeTransitionsBackwardsCompat: Bool {
|
||||
if #available(iOS 14.0, *) {
|
||||
return prefersCrossFadeTransitions
|
||||
} else {
|
||||
return isReduceMotionEnabled
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
//
|
||||
// UIBezierPath+Helpers.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 6/25/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
// TODO: write unit tests for this
|
||||
extension UIBezierPath {
|
||||
|
||||
/// Create a new UIBezierPath that wraps around the given array of rectangles.
|
||||
/// This is not a convex hull aglorithm. What this does is it takes a set of rectangles
|
||||
/// and draws a line around the outer borders of the combined shape.
|
||||
convenience init(wrappingAround rects: [CGRect]) {
|
||||
precondition(rects.count > 0)
|
||||
|
||||
if rects.count == 1 {
|
||||
self.init(rect: rects.first!)
|
||||
return
|
||||
}
|
||||
|
||||
let rects = rects.sorted { $0.minY < $1.minY }
|
||||
|
||||
self.init()
|
||||
|
||||
// start at the top left corner
|
||||
self.move(to: CGPoint(x: rects.first!.minX, y: rects.first!.minY))
|
||||
|
||||
// walk down the left side
|
||||
var prevLeft = rects.first!.minX
|
||||
for rect in rects where !rect.minX.isEqual(to: prevLeft) {
|
||||
self.addLine(to: CGPoint(x: prevLeft, y: rect.minY))
|
||||
self.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
|
||||
prevLeft = rect.minX
|
||||
}
|
||||
|
||||
// ensure at the bottom left if not already
|
||||
let bottomLeft = CGPoint(x: rects.last!.minX, y: rects.last!.maxY)
|
||||
if !self.currentPoint.equalTo(bottomLeft) {
|
||||
self.addLine(to: bottomLeft)
|
||||
}
|
||||
|
||||
// across the bottom of the last rect
|
||||
self.addLine(to: CGPoint(x: rects.last!.maxX, y: rects.last!.maxY))
|
||||
|
||||
// walk up the right side
|
||||
var prevRight = rects.last!.maxX
|
||||
for rect in rects.reversed() where !rect.maxX.isEqual(to: prevRight) {
|
||||
self.addLine(to: CGPoint(x: prevRight, y: rect.maxY))
|
||||
self.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
|
||||
prevRight = rect.maxX
|
||||
}
|
||||
|
||||
// ensure at the top right if not already
|
||||
let topRight = CGPoint(x: rects.first!.maxX, y: rects.first!.minY)
|
||||
if !self.currentPoint.equalTo(topRight) {
|
||||
self.addLine(to: topRight)
|
||||
}
|
||||
|
||||
// across the top of the first rect
|
||||
self.addLine(to: CGPoint(x: rects.first!.minX, y: rects.first!.minY))
|
||||
}
|
||||
|
||||
}
|
|
@ -10,23 +10,17 @@ import UIKit
|
|||
|
||||
extension UIViewController: UIViewControllerTransitioningDelegate {
|
||||
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
if let presented = presented as? LargeImageViewController,
|
||||
presented.sourceInfo?.image != nil {
|
||||
if let presented = presented as? LargeImageAnimatableViewController,
|
||||
presented.animationImage != nil {
|
||||
return LargeImageExpandAnimationController()
|
||||
} else if let presented = presented as? GalleryViewController,
|
||||
presented.sourcesInfo[presented.startIndex]?.image != nil {
|
||||
return GalleryExpandAnimationController()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
if let dismissed = dismissed as? LargeImageViewController,
|
||||
dismissed.imageForDismissalAnimation() != nil {
|
||||
if let dismissed = dismissed as? LargeImageAnimatableViewController,
|
||||
dismissed.animationImage != nil {
|
||||
return LargeImageShrinkAnimationController(interactionController: dismissed.dismissInteractionController)
|
||||
} else if let dismissed = dismissed as? GalleryViewController,
|
||||
dismissed.imageForDismissalAnimation() != nil {
|
||||
return GalleryShrinkAnimationController(interactionController: dismissed.dismissInteractionController)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -36,10 +30,6 @@ extension UIViewController: UIViewControllerTransitioningDelegate {
|
|||
let interactionController = animator.interactionController,
|
||||
interactionController.inProgress {
|
||||
return interactionController
|
||||
} else if let animator = animator as? GalleryShrinkAnimationController,
|
||||
let interactionController = animator.interactionController,
|
||||
interactionController.inProgress {
|
||||
return interactionController
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
//
|
||||
// View+ConditionalModifier.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/31/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
|
||||
@ViewBuilder
|
||||
func conditionally<Modified: View>(_ condition: Bool, modifier: (Self) -> Modified) -> some View {
|
||||
if condition {
|
||||
modifier(self)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -37,4 +37,17 @@ extension Status.Visibility {
|
|||
}
|
||||
}
|
||||
|
||||
var unfilledImageName: String {
|
||||
switch self {
|
||||
case .public:
|
||||
return "globe"
|
||||
case .unlisted:
|
||||
return "lock.open"
|
||||
case .private:
|
||||
return "lock"
|
||||
case .direct:
|
||||
return "envelope"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
@ -30,7 +30,9 @@
|
|||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.social-networking</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
@ -98,5 +100,16 @@
|
|||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array/>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>space.vaccor.Tusker.composition-attachment</string>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
//
|
||||
// LazyDecoding.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 4/11/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
private let decoder = PropertyListDecoder()
|
||||
private let encoder = PropertyListEncoder()
|
||||
|
||||
// todo: invalidate cache on underlying data change using KVO?
|
||||
@propertyWrapper
|
||||
public struct LazilyDecoding<Enclosing, Value: Codable> {
|
||||
|
||||
private let keyPath: ReferenceWritableKeyPath<Enclosing, Data?>
|
||||
private let fallback: Value
|
||||
private var value: Value?
|
||||
|
||||
init(from keyPath: ReferenceWritableKeyPath<Enclosing, Data?>, fallback: Value) {
|
||||
self.keyPath = keyPath
|
||||
self.fallback = fallback
|
||||
}
|
||||
|
||||
public var wrappedValue: Value {
|
||||
get { fatalError("called LazilyDecoding wrappedValue getter") }
|
||||
set { fatalError("called LazilyDecoding wrappedValue setter") }
|
||||
}
|
||||
|
||||
public static subscript(_enclosingInstance instance: Enclosing, wrapped wrappedKeyPath: ReferenceWritableKeyPath<Enclosing, Value>, storage storageKeyPath: ReferenceWritableKeyPath<Enclosing, Self>) -> Value {
|
||||
get {
|
||||
var wrapper = instance[keyPath: storageKeyPath]
|
||||
if let value = wrapper.value {
|
||||
return value
|
||||
} else {
|
||||
guard let data = instance[keyPath: wrapper.keyPath] else { return wrapper.fallback }
|
||||
do {
|
||||
let value = try decoder.decode(Value.self, from: data)
|
||||
wrapper.value = value
|
||||
instance[keyPath: storageKeyPath] = wrapper
|
||||
return value
|
||||
} catch {
|
||||
return wrapper.fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
set {
|
||||
var wrapper = instance[keyPath: storageKeyPath]
|
||||
wrapper.value = newValue
|
||||
instance[keyPath: storageKeyPath] = wrapper
|
||||
let newData = try? encoder.encode(newValue)
|
||||
instance[keyPath: wrapper.keyPath] = newData
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension LazilyDecoding {
|
||||
init(arrayFrom keyPath: ReferenceWritableKeyPath<Enclosing, Data?>) {
|
||||
self.init(from: keyPath, fallback: [] as! Value)
|
||||
}
|
||||
}
|
|
@ -18,6 +18,7 @@ class LocalData: ObservableObject {
|
|||
private init() {
|
||||
if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING") {
|
||||
defaults = UserDefaults(suiteName: "\(Bundle.main.bundleIdentifier!).uitesting")!
|
||||
defaults.removePersistentDomain(forName: "\(Bundle.main.bundleIdentifier!).uitesting")
|
||||
if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING_LOGIN") {
|
||||
accounts = [
|
||||
UserAccountInfo(
|
||||
|
@ -30,7 +31,20 @@ class LocalData: ObservableObject {
|
|||
]
|
||||
}
|
||||
} else {
|
||||
defaults = UserDefaults()
|
||||
defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")!
|
||||
tryMigrateOldDefaults()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: remove me before public beta
|
||||
private func tryMigrateOldDefaults() {
|
||||
let old = UserDefaults()
|
||||
if let accounts = old.array(forKey: accountsKey) as? [[String: String]],
|
||||
let mostRecentAccount = old.string(forKey: mostRecentAccountKey) {
|
||||
defaults.setValue(accounts, forKey: accountsKey)
|
||||
defaults.setValue(mostRecentAccount, forKey: mostRecentAccountKey)
|
||||
old.removeObject(forKey: accountsKey)
|
||||
old.removeObject(forKey: mostRecentAccountKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -44,11 +58,10 @@ class LocalData: ObservableObject {
|
|||
let url = URL(string: instanceURL),
|
||||
let clientId = info["clientID"],
|
||||
let secret = info["clientSecret"],
|
||||
let username = info["username"],
|
||||
let accessToken = info["accessToken"] else {
|
||||
return nil
|
||||
}
|
||||
return UserAccountInfo(id: id, instanceURL: url, clientID: clientId, clientSecret: secret, username: username, accessToken: accessToken)
|
||||
return UserAccountInfo(id: id, instanceURL: url, clientID: clientId, clientSecret: secret, username: info["username"], accessToken: accessToken)
|
||||
}
|
||||
} else {
|
||||
return []
|
||||
|
@ -56,15 +69,18 @@ class LocalData: ObservableObject {
|
|||
}
|
||||
set {
|
||||
objectWillChange.send()
|
||||
let array = newValue.map { (info) in
|
||||
return [
|
||||
let array = newValue.map { (info) -> [String: String] in
|
||||
var res = [
|
||||
"id": info.id,
|
||||
"instanceURL": info.instanceURL.absoluteString,
|
||||
"clientID": info.clientID,
|
||||
"clientSecret": info.clientSecret,
|
||||
"username": info.username,
|
||||
"accessToken": info.accessToken
|
||||
]
|
||||
if let username = info.username {
|
||||
res["username"] = username
|
||||
}
|
||||
return res
|
||||
}
|
||||
defaults.set(array, forKey: accountsKey)
|
||||
}
|
||||
|
@ -85,7 +101,7 @@ class LocalData: ObservableObject {
|
|||
return !accounts.isEmpty
|
||||
}
|
||||
|
||||
func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String, accessToken: String) -> UserAccountInfo {
|
||||
func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String?, accessToken: String) -> UserAccountInfo {
|
||||
var accounts = self.accounts
|
||||
if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) {
|
||||
accounts.remove(at: index)
|
||||
|
@ -97,6 +113,13 @@ class LocalData: ObservableObject {
|
|||
return info
|
||||
}
|
||||
|
||||
func setUsername(for info: UserAccountInfo, username: String) {
|
||||
var info = info
|
||||
info.username = username
|
||||
removeAccount(info)
|
||||
accounts.append(info)
|
||||
}
|
||||
|
||||
func removeAccount(_ info: UserAccountInfo) {
|
||||
accounts.removeAll(where: { $0.id == info.id })
|
||||
}
|
||||
|
@ -128,7 +151,7 @@ extension LocalData {
|
|||
let instanceURL: URL
|
||||
let clientID: String
|
||||
let clientSecret: String
|
||||
let username: String
|
||||
fileprivate(set) var username: String!
|
||||
let accessToken: String
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
|
|
|
@ -1,177 +0,0 @@
|
|||
//
|
||||
// StatusCache.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 9/17/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import Pachyderm
|
||||
|
||||
class MastodonCache {
|
||||
|
||||
private var statuses = CachedDictionary<Status>(name: "Statuses")
|
||||
private var accounts = CachedDictionary<Account>(name: "Accounts")
|
||||
private var relationships = CachedDictionary<Relationship>(name: "Relationships")
|
||||
private var notifications = CachedDictionary<Pachyderm.Notification>(name: "Notifications")
|
||||
|
||||
let statusSubject = PassthroughSubject<Status, Never>()
|
||||
let accountSubject = PassthroughSubject<Account, Never>()
|
||||
|
||||
weak var mastodonController: MastodonController?
|
||||
|
||||
init(mastodonController: MastodonController) {
|
||||
self.mastodonController = mastodonController
|
||||
}
|
||||
|
||||
// MARK: - Statuses
|
||||
func status(for id: String) -> Status? {
|
||||
return statuses[id]
|
||||
}
|
||||
|
||||
func set(status: Status, for id: String) {
|
||||
statuses[id] = status
|
||||
add(account: status.account)
|
||||
if let reblog = status.reblog {
|
||||
add(status: reblog)
|
||||
add(account: reblog.account)
|
||||
}
|
||||
|
||||
statusSubject.send(status)
|
||||
}
|
||||
|
||||
func status(for id: String, completion: @escaping (Status?) -> Void) {
|
||||
guard let mastodonController = mastodonController else {
|
||||
fatalError("The MastodonController for this cache has been deinitialized, so this cache should no longer exist. Are you storing a strong reference to it?")
|
||||
}
|
||||
let request = Client.getStatus(id: id)
|
||||
mastodonController.run(request) { response in
|
||||
guard case let .success(status, _) = response else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
self.set(status: status, for: id)
|
||||
completion(status)
|
||||
}
|
||||
}
|
||||
|
||||
func add(status: Status) {
|
||||
set(status: status, for: status.id)
|
||||
}
|
||||
|
||||
func addAll(statuses: [Status]) {
|
||||
statuses.forEach(add)
|
||||
}
|
||||
|
||||
// MARK: - Accounts
|
||||
func account(for id: String) -> Account? {
|
||||
return accounts[id]
|
||||
}
|
||||
|
||||
func set(account: Account, for id: String) {
|
||||
accounts[id] = account
|
||||
accountSubject.send(account)
|
||||
}
|
||||
|
||||
func account(for id: String, completion: @escaping (Account?) -> Void) {
|
||||
guard let mastodonController = mastodonController else {
|
||||
fatalError("The MastodonController for this cache has been deinitialized, so this cache should no longer exist. Are you storing a strong reference to it?")
|
||||
}
|
||||
let request = Client.getAccount(id: id)
|
||||
mastodonController.run(request) { response in
|
||||
guard case let .success(account, _) = response else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
self.set(account: account, for: account.id)
|
||||
completion(account)
|
||||
}
|
||||
}
|
||||
|
||||
func add(account: Account) {
|
||||
set(account: account, for: account.id)
|
||||
}
|
||||
|
||||
func addAll(accounts: [Account]) {
|
||||
accounts.forEach(add)
|
||||
}
|
||||
|
||||
// MARK: - Relationships
|
||||
func relationship(for id: String) -> Relationship? {
|
||||
return relationships[id]
|
||||
}
|
||||
|
||||
func set(relationship: Relationship, id: String) {
|
||||
relationships[id] = relationship
|
||||
}
|
||||
|
||||
func relationship(for id: String, completion: @escaping (Relationship?) -> Void) {
|
||||
guard let mastodonController = mastodonController else {
|
||||
fatalError("The MastodonController for this cache has been deinitialized, so this cache should no longer exist. Are you storing a strong reference to it?")
|
||||
}
|
||||
let request = Client.getRelationships(accounts: [id])
|
||||
mastodonController.run(request) { response in
|
||||
guard case let .success(relationships, _) = response,
|
||||
let relationship = relationships.first else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
self.set(relationship: relationship, id: relationship.id)
|
||||
completion(relationship)
|
||||
}
|
||||
}
|
||||
|
||||
func add(relationship: Relationship) {
|
||||
set(relationship: relationship, id: relationship.id)
|
||||
}
|
||||
|
||||
func addAll(relationships: [Relationship]) {
|
||||
relationships.forEach(add)
|
||||
}
|
||||
|
||||
// MARK: - Notifications
|
||||
func notification(for id: String) -> Pachyderm.Notification? {
|
||||
return notifications[id]
|
||||
}
|
||||
|
||||
func set(notification: Pachyderm.Notification, id: String) {
|
||||
notifications[id] = notification
|
||||
}
|
||||
|
||||
func add(notification: Pachyderm.Notification) {
|
||||
set(notification: notification, id: notification.id)
|
||||
}
|
||||
|
||||
func addAll(notifications: [Pachyderm.Notification]) {
|
||||
notifications.forEach(add)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class CachedDictionary<Value> {
|
||||
private let name: String
|
||||
private var dict = [String: Value]()
|
||||
private let queue: DispatchQueue
|
||||
|
||||
init(name: String) {
|
||||
self.name = name
|
||||
self.queue = DispatchQueue(label: "CachedDictionary (\(name)) Coordinator", attributes: .concurrent)
|
||||
}
|
||||
|
||||
subscript(key: String) -> Value? {
|
||||
get {
|
||||
var result: Value? = nil
|
||||
queue.sync {
|
||||
result = dict[key]
|
||||
}
|
||||
return result
|
||||
}
|
||||
set(value) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.dict[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
//
|
||||
// CompositionAttachment.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 3/14/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import MobileCoreServices
|
||||
|
||||
final class CompositionAttachment: NSObject, Codable, ObservableObject {
|
||||
static let typeIdentifier = "space.vaccor.Tusker.composition-attachment"
|
||||
|
||||
let id: UUID
|
||||
@Published var data: CompositionAttachmentData
|
||||
@Published var attachmentDescription: String
|
||||
|
||||
init(data: CompositionAttachmentData, description: String = "") {
|
||||
self.id = UUID()
|
||||
self.data = data
|
||||
self.attachmentDescription = description
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.id = try container.decode(UUID.self, forKey: .id)
|
||||
self.data = try container.decode(CompositionAttachmentData.self, forKey: .data)
|
||||
self.attachmentDescription = try container.decode(String.self, forKey: .attachmentDescription)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(data, forKey: .data)
|
||||
try container.encode(attachmentDescription, forKey: .attachmentDescription)
|
||||
}
|
||||
|
||||
static func ==(lhs: CompositionAttachment, rhs: CompositionAttachment) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case data
|
||||
case attachmentDescription
|
||||
}
|
||||
}
|
||||
|
||||
extension CompositionAttachment: Identifiable {}
|
||||
|
||||
private let imageType = kUTTypeImage as String
|
||||
private let mp4Type = kUTTypeMPEG4 as String
|
||||
private let quickTimeType = kUTTypeQuickTimeMovie as String
|
||||
private let dataType = kUTTypeData as String
|
||||
|
||||
extension CompositionAttachment: NSItemProviderWriting {
|
||||
static var writableTypeIdentifiersForItemProvider: [String] {
|
||||
[typeIdentifier]
|
||||
}
|
||||
|
||||
func loadData(withTypeIdentifier typeIdentifier: String, forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void) -> Progress? {
|
||||
if typeIdentifier == CompositionAttachment.typeIdentifier {
|
||||
do {
|
||||
completionHandler(try PropertyListEncoder().encode(self), nil)
|
||||
} catch {
|
||||
completionHandler(nil, error)
|
||||
}
|
||||
}
|
||||
|
||||
completionHandler(nil, ItemProviderError.incompatibleTypeIdentifier)
|
||||
return nil
|
||||
}
|
||||
|
||||
enum ItemProviderError: Error {
|
||||
case incompatibleTypeIdentifier
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .incompatibleTypeIdentifier:
|
||||
return "Cannot provide data for given type"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CompositionAttachment: NSItemProviderReading {
|
||||
static var readableTypeIdentifiersForItemProvider: [String] {
|
||||
// todo: is there a better way of handling movies than manually adding all possible UTI types?
|
||||
// just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension
|
||||
// without the file extension, getting the thumbnail and exporting the video for attachment upload fails
|
||||
[typeIdentifier] + UIImage.readableTypeIdentifiersForItemProvider + [mp4Type, quickTimeType] + NSURL.readableTypeIdentifiersForItemProvider
|
||||
}
|
||||
|
||||
static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> Self {
|
||||
if typeIdentifier == CompositionAttachment.typeIdentifier {
|
||||
return try PropertyListDecoder().decode(Self.self, from: data)
|
||||
} else if UIImage.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let image = try? UIImage.object(withItemProviderData: data, typeIdentifier: typeIdentifier) {
|
||||
return CompositionAttachment(data: .image(image)) as! Self
|
||||
} else if typeIdentifier == mp4Type || typeIdentifier == quickTimeType {
|
||||
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
let temporaryFileName = ProcessInfo().globallyUniqueString
|
||||
let fileExt = UTTypeCopyPreferredTagWithClass(typeIdentifier as CFString, kUTTagClassFilenameExtension)!
|
||||
let temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(temporaryFileName).appendingPathExtension(fileExt.takeUnretainedValue() as String)
|
||||
try data.write(to: temporaryFileURL)
|
||||
return CompositionAttachment(data: .video(temporaryFileURL)) as! Self
|
||||
} else if NSURL.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let url = try? NSURL.object(withItemProviderData: data, typeIdentifier: typeIdentifier) as URL {
|
||||
return CompositionAttachment(data: .video(url)) as! Self
|
||||
} else {
|
||||
throw ItemProviderError.incompatibleTypeIdentifier
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// CompositionAttachment.swift
|
||||
// CompositionAttachmentData.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 1/1/20.
|
||||
|
@ -9,11 +9,13 @@
|
|||
import UIKit
|
||||
import Photos
|
||||
import MobileCoreServices
|
||||
import PencilKit
|
||||
|
||||
enum CompositionAttachment {
|
||||
enum CompositionAttachmentData {
|
||||
case asset(PHAsset)
|
||||
case image(UIImage)
|
||||
case video(URL)
|
||||
case drawing(PKDrawing)
|
||||
|
||||
var type: AttachmentType {
|
||||
switch self {
|
||||
|
@ -23,6 +25,8 @@ enum CompositionAttachment {
|
|||
return .image
|
||||
case .video(_):
|
||||
return .video
|
||||
case .drawing(_):
|
||||
return .image
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -44,7 +48,7 @@ enum CompositionAttachment {
|
|||
}
|
||||
}
|
||||
|
||||
func getData(completion: @escaping (Data, String) -> Void) {
|
||||
func getData(completion: @escaping (_ data: Data, _ mimeType: String) -> Void) {
|
||||
switch self {
|
||||
case let .image(image):
|
||||
completion(image.pngData()!, "image/png")
|
||||
|
@ -79,7 +83,7 @@ enum CompositionAttachment {
|
|||
options.version = .current
|
||||
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { (exportSession, info) in
|
||||
guard let exportSession = exportSession else { fatalError("failed to create export session") }
|
||||
CompositionAttachment.exportVideoData(session: exportSession, completion: completion)
|
||||
CompositionAttachmentData.exportVideoData(session: exportSession, completion: completion)
|
||||
}
|
||||
} else {
|
||||
fatalError("assetType must be either image or video")
|
||||
|
@ -89,7 +93,11 @@ enum CompositionAttachment {
|
|||
guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
|
||||
fatalError("failed to create export session")
|
||||
}
|
||||
CompositionAttachment.exportVideoData(session: session, completion: completion)
|
||||
CompositionAttachmentData.exportVideoData(session: session, completion: completion)
|
||||
|
||||
case let .drawing(drawing):
|
||||
let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1)
|
||||
completion(image.pngData()!, "image/png")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -113,7 +121,7 @@ enum CompositionAttachment {
|
|||
}
|
||||
|
||||
extension PHAsset {
|
||||
var attachmentType: CompositionAttachment.AttachmentType? {
|
||||
var attachmentType: CompositionAttachmentData.AttachmentType? {
|
||||
switch self.mediaType {
|
||||
case .image:
|
||||
return .image
|
||||
|
@ -125,7 +133,7 @@ extension PHAsset {
|
|||
}
|
||||
}
|
||||
|
||||
extension CompositionAttachment: Codable {
|
||||
extension CompositionAttachmentData: Codable {
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
|
@ -138,6 +146,10 @@ extension CompositionAttachment: Codable {
|
|||
try container.encode(image.pngData()!, forKey: .imageData)
|
||||
case .video(_):
|
||||
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: [], debugDescription: "video CompositionAttachments cannot be encoded"))
|
||||
case let .drawing(drawing):
|
||||
try container.encode("drawing", forKey: .type)
|
||||
let drawingData = drawing.dataRepresentation()
|
||||
try container.encode(drawingData, forKey: .drawing)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -156,6 +168,10 @@ extension CompositionAttachment: Codable {
|
|||
throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "Could not decode UIImage from image data")
|
||||
}
|
||||
self = .image(image)
|
||||
case "drawing":
|
||||
let drawingData = try container.decode(Data.self, forKey: .drawing)
|
||||
let drawing = try PKDrawing(data: drawingData)
|
||||
self = .drawing(drawing)
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of 'image' or 'asset'")
|
||||
}
|
||||
|
@ -166,16 +182,22 @@ extension CompositionAttachment: Codable {
|
|||
case imageData
|
||||
/// The local identifier of the PHAsset for this attachment
|
||||
case assetIdentifier
|
||||
/// The PKDrawing object for this attachment.
|
||||
case drawing
|
||||
}
|
||||
}
|
||||
|
||||
extension CompositionAttachment: Equatable {
|
||||
static func ==(lhs: CompositionAttachment, rhs: CompositionAttachment) -> Bool {
|
||||
extension CompositionAttachmentData: Equatable {
|
||||
static func ==(lhs: CompositionAttachmentData, rhs: CompositionAttachmentData) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case let (.asset(a), .asset(b)):
|
||||
return a.localIdentifier == b.localIdentifier
|
||||
case let (.image(a), .image(b)):
|
||||
return a == b
|
||||
case let (.video(a), .video(b)):
|
||||
return a == b
|
||||
case let (.drawing(a), .drawing(b)):
|
||||
return a == b
|
||||
default:
|
||||
return false
|
||||
}
|
|
@ -0,0 +1,164 @@
|
|||
//
|
||||
// Draft.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/18/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Pachyderm
|
||||
|
||||
class Draft: Codable, ObservableObject {
|
||||
let id: UUID
|
||||
var lastModified: Date
|
||||
|
||||
@Published var accountID: String
|
||||
@Published var text: String
|
||||
@Published var contentWarningEnabled: Bool
|
||||
@Published var contentWarning: String
|
||||
@Published var attachments: [CompositionAttachment]
|
||||
@Published var inReplyToID: String?
|
||||
@Published var visibility: Status.Visibility
|
||||
|
||||
var initialText: String
|
||||
|
||||
var hasContent: Bool {
|
||||
(!text.isEmpty && text != initialText) ||
|
||||
(contentWarningEnabled && !contentWarning.isEmpty) ||
|
||||
attachments.count > 0
|
||||
}
|
||||
|
||||
init(accountID: String) {
|
||||
self.id = UUID()
|
||||
self.lastModified = Date()
|
||||
|
||||
self.accountID = accountID
|
||||
self.text = ""
|
||||
self.contentWarningEnabled = false
|
||||
self.contentWarning = ""
|
||||
self.attachments = []
|
||||
self.inReplyToID = nil
|
||||
self.visibility = Preferences.shared.defaultPostVisibility
|
||||
|
||||
self.initialText = ""
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.id = try container.decode(UUID.self, forKey: .id)
|
||||
self.lastModified = try container.decode(Date.self, forKey: .lastModified)
|
||||
|
||||
self.accountID = try container.decode(String.self, forKey: .accountID)
|
||||
self.text = try container.decode(String.self, forKey: .text)
|
||||
if let enabled = try? container.decode(Bool.self, forKey: .contentWarningEnabled) {
|
||||
self.contentWarningEnabled = enabled
|
||||
self.contentWarning = try container.decode(String.self, forKey: .contentWarning)
|
||||
} else {
|
||||
// todo: temporary until migration away from old drafts manager is complete
|
||||
let cw = try container.decode(String?.self, forKey: .contentWarning)
|
||||
if let cw = cw {
|
||||
self.contentWarningEnabled = !cw.isEmpty
|
||||
self.contentWarning = cw
|
||||
} else {
|
||||
self.contentWarningEnabled = false
|
||||
self.contentWarning = ""
|
||||
}
|
||||
}
|
||||
self.attachments = try container.decode([CompositionAttachment].self, forKey: .attachments)
|
||||
self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID)
|
||||
self.visibility = try container.decode(Status.Visibility.self, forKey: .visibility)
|
||||
|
||||
self.initialText = try container.decode(String.self, forKey: .initialText)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(lastModified, forKey: .lastModified)
|
||||
|
||||
try container.encode(accountID, forKey: .accountID)
|
||||
try container.encode(text, forKey: .text)
|
||||
try container.encode(contentWarningEnabled, forKey: .contentWarningEnabled)
|
||||
try container.encode(contentWarning, forKey: .contentWarning)
|
||||
try container.encode(attachments, forKey: .attachments)
|
||||
try container.encode(inReplyToID, forKey: .inReplyToID)
|
||||
try container.encode(visibility, forKey: .visibility)
|
||||
|
||||
try container.encode(initialText, forKey: .initialText)
|
||||
}
|
||||
}
|
||||
|
||||
extension Draft: Equatable {
|
||||
static func ==(lhs: Draft, rhs: Draft) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
extension Draft {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case lastModified
|
||||
|
||||
case accountID
|
||||
case text
|
||||
case contentWarningEnabled
|
||||
case contentWarning
|
||||
case attachments
|
||||
case inReplyToID
|
||||
case visibility
|
||||
|
||||
case initialText
|
||||
}
|
||||
}
|
||||
|
||||
extension MastodonController {
|
||||
|
||||
func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil) -> Draft {
|
||||
var acctsToMention = [String]()
|
||||
|
||||
var visibility = Preferences.shared.defaultPostVisibility
|
||||
var contentWarning = ""
|
||||
|
||||
if let inReplyToID = inReplyToID,
|
||||
let inReplyTo = persistentContainer.status(for: inReplyToID) {
|
||||
acctsToMention.append(inReplyTo.account.acct)
|
||||
acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct))
|
||||
visibility = inReplyTo.visibility
|
||||
|
||||
if !inReplyTo.spoilerText.isEmpty {
|
||||
switch Preferences.shared.contentWarningCopyMode {
|
||||
case .doNotCopy:
|
||||
break
|
||||
case .asIs:
|
||||
contentWarning = inReplyTo.spoilerText
|
||||
case .prependRe:
|
||||
if inReplyTo.spoilerText.lowercased().starts(with: "re:") {
|
||||
contentWarning = inReplyTo.spoilerText
|
||||
} else {
|
||||
contentWarning = "re: \(inReplyTo.spoilerText)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let mentioningAcct = mentioningAcct {
|
||||
acctsToMention.append(mentioningAcct)
|
||||
}
|
||||
if let ownAccount = self.account {
|
||||
acctsToMention.removeAll(where: { $0 == ownAccount.acct })
|
||||
}
|
||||
acctsToMention = acctsToMention.uniques()
|
||||
|
||||
let draft = Draft(accountID: accountInfo!.id)
|
||||
draft.inReplyToID = inReplyToID
|
||||
draft.text = acctsToMention.map { "@\($0) " }.joined()
|
||||
draft.initialText = draft.text
|
||||
draft.visibility = visibility
|
||||
draft.contentWarning = contentWarning
|
||||
draft.contentWarningEnabled = !contentWarning.isEmpty
|
||||
return draft
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
//
|
||||
// DraftsManager.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 10/22/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class DraftsManager: Codable {
|
||||
|
||||
private(set) static var shared: DraftsManager = load()
|
||||
|
||||
private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
private static var archiveURL = DraftsManager.documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
|
||||
|
||||
static func save() {
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let encoder = PropertyListEncoder()
|
||||
let data = try? encoder.encode(shared)
|
||||
try? data?.write(to: archiveURL, options: .noFileProtection)
|
||||
}
|
||||
}
|
||||
|
||||
static func load() -> DraftsManager {
|
||||
let decoder = PropertyListDecoder()
|
||||
if let data = try? Data(contentsOf: archiveURL),
|
||||
let draftsManager = try? decoder.decode(DraftsManager.self, from: data) {
|
||||
return draftsManager
|
||||
}
|
||||
return DraftsManager()
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
var drafts: [Draft] = []
|
||||
var sorted: [Draft] {
|
||||
return drafts.sorted(by: { $0.lastModified > $1.lastModified })
|
||||
}
|
||||
|
||||
func add(_ draft: Draft) {
|
||||
drafts.append(draft)
|
||||
}
|
||||
|
||||
func remove(_ draft: Draft) {
|
||||
drafts.removeAll { $0 == draft }
|
||||
}
|
||||
|
||||
}
|
|
@ -38,19 +38,29 @@ class Preferences: Codable, ObservableObject {
|
|||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme)
|
||||
self.showRepliesInProfiles = try container.decode(Bool.self, forKey: .showRepliesInProfiles)
|
||||
self.avatarStyle = try container.decode(AvatarStyle.self, forKey: .avatarStyle)
|
||||
self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames)
|
||||
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
|
||||
self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon)
|
||||
|
||||
self.defaultPostVisibility = try container.decode(Status.Visibility.self, forKey: .defaultPostVisibility)
|
||||
self.automaticallySaveDrafts = try container.decode(Bool.self, forKey: .automaticallySaveDrafts)
|
||||
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
|
||||
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
|
||||
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
|
||||
|
||||
self.blurAllMedia = try container.decode(Bool.self, forKey: .blurAllMedia)
|
||||
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
|
||||
|
||||
self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
|
||||
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
|
||||
self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode)
|
||||
if container.contains(.expandAllContentWarnings) {
|
||||
self.expandAllContentWarnings = try container.decode(Bool.self, forKey: .expandAllContentWarnings)
|
||||
}
|
||||
if container.contains(.collapseLongPosts) {
|
||||
self.collapseLongPosts = try container.decode(Bool.self, forKey: .collapseLongPosts)
|
||||
}
|
||||
|
||||
self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
|
||||
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
|
||||
|
@ -63,19 +73,25 @@ class Preferences: Codable, ObservableObject {
|
|||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encode(theme, forKey: .theme)
|
||||
try container.encode(showRepliesInProfiles, forKey: .showRepliesInProfiles)
|
||||
try container.encode(avatarStyle, forKey: .avatarStyle)
|
||||
try container.encode(hideCustomEmojiInUsernames, forKey: .hideCustomEmojiInUsernames)
|
||||
try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon)
|
||||
try container.encode(alwaysShowStatusVisibilityIcon, forKey: .alwaysShowStatusVisibilityIcon)
|
||||
|
||||
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
|
||||
try container.encode(automaticallySaveDrafts, forKey: .automaticallySaveDrafts)
|
||||
try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions)
|
||||
try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode)
|
||||
try container.encode(mentionReblogger, forKey: .mentionReblogger)
|
||||
|
||||
try container.encode(blurAllMedia, forKey: .blurAllMedia)
|
||||
try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
|
||||
|
||||
try container.encode(openLinksInApps, forKey: .openLinksInApps)
|
||||
try container.encode(useInAppSafari, forKey: .useInAppSafari)
|
||||
try container.encode(inAppSafariAutomaticReaderMode, forKey: .inAppSafariAutomaticReaderMode)
|
||||
try container.encode(expandAllContentWarnings, forKey: .expandAllContentWarnings)
|
||||
try container.encode(collapseLongPosts, forKey: .collapseLongPosts)
|
||||
|
||||
try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts)
|
||||
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
|
||||
|
@ -84,46 +100,60 @@ class Preferences: Codable, ObservableObject {
|
|||
try container.encode(statusContentType, forKey: .statusContentType)
|
||||
}
|
||||
|
||||
// MARK: - Appearance
|
||||
// MARK: Appearance
|
||||
@Published var theme = UIUserInterfaceStyle.unspecified
|
||||
@Published var showRepliesInProfiles = false
|
||||
@Published var avatarStyle = AvatarStyle.roundRect
|
||||
@Published var hideCustomEmojiInUsernames = false
|
||||
@Published var showIsStatusReplyIcon = false
|
||||
@Published var alwaysShowStatusVisibilityIcon = false
|
||||
|
||||
// MARK: - Behavior
|
||||
// MARK: Composing
|
||||
@Published var defaultPostVisibility = Status.Visibility.public
|
||||
@Published var automaticallySaveDrafts = true
|
||||
@Published var requireAttachmentDescriptions = false
|
||||
@Published var contentWarningCopyMode = ContentWarningCopyMode.asIs
|
||||
@Published var mentionReblogger = false
|
||||
|
||||
// MARK: Media
|
||||
@Published var blurAllMedia = false
|
||||
@Published var automaticallyPlayGifs = true
|
||||
|
||||
// MARK: Behavior
|
||||
@Published var openLinksInApps = true
|
||||
@Published var useInAppSafari = true
|
||||
@Published var inAppSafariAutomaticReaderMode = false
|
||||
@Published var expandAllContentWarnings = false
|
||||
@Published var collapseLongPosts = true
|
||||
|
||||
// MARK: - Digital Wellness
|
||||
// MARK: Digital Wellness
|
||||
@Published var showFavoriteAndReblogCounts = true
|
||||
@Published var defaultNotificationsMode = NotificationsMode.allNotifications
|
||||
|
||||
// MARK: - Advanced
|
||||
// MARK: Advanced
|
||||
@Published var silentActions: [String: Permission] = [:]
|
||||
@Published var statusContentType: StatusContentType = .plain
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case theme
|
||||
case showRepliesInProfiles
|
||||
case avatarStyle
|
||||
case hideCustomEmojiInUsernames
|
||||
case showIsStatusReplyIcon
|
||||
case alwaysShowStatusVisibilityIcon
|
||||
|
||||
case defaultPostVisibility
|
||||
case automaticallySaveDrafts
|
||||
case requireAttachmentDescriptions
|
||||
case contentWarningCopyMode
|
||||
case mentionReblogger
|
||||
|
||||
case blurAllMedia
|
||||
case automaticallyPlayGifs
|
||||
|
||||
case openLinksInApps
|
||||
case useInAppSafari
|
||||
case inAppSafariAutomaticReaderMode
|
||||
case expandAllContentWarnings
|
||||
case collapseLongPosts
|
||||
|
||||
case showFavoriteAndReblogCounts
|
||||
case defaultNotificationsType
|
||||
|
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import CrashReporter
|
||||
import MessageUI
|
||||
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
|
||||
|
@ -21,15 +23,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
|
||||
window = UIWindow(windowScene: windowScene)
|
||||
|
||||
if LocalData.shared.onboardingComplete {
|
||||
if session.mastodonController == nil {
|
||||
let account = LocalData.shared.getMostRecentAccount()!
|
||||
session.mastodonController = MastodonController.getForAccount(account)
|
||||
}
|
||||
|
||||
showAppUI()
|
||||
if let report = AppDelegate.pendingCrashReport {
|
||||
AppDelegate.pendingCrashReport = nil
|
||||
handlePendingCrashReport(report, session: session)
|
||||
} else {
|
||||
showOnboardingUI()
|
||||
showAppOrOnboardingUI(session: session)
|
||||
}
|
||||
|
||||
window!.makeKeyAndVisible()
|
||||
|
@ -82,6 +80,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
// This occurs shortly after the scene enters the background, or when its session is discarded.
|
||||
// Release any resources associated with this scene that can be re-created the next time the scene connects.
|
||||
// The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead).
|
||||
|
||||
Preferences.save()
|
||||
DraftsManager.save()
|
||||
}
|
||||
|
||||
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||
|
@ -92,6 +93,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
func sceneWillResignActive(_ scene: UIScene) {
|
||||
// Called when the scene will move from an active state to an inactive state.
|
||||
// This may occur due to temporary interruptions (ex. an incoming phone call).
|
||||
|
||||
Preferences.save()
|
||||
DraftsManager.save()
|
||||
}
|
||||
|
||||
func sceneWillEnterForeground(_ scene: UIScene) {
|
||||
|
@ -104,8 +108,33 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
// Use this method to save data, release shared resources, and store enough scene-specific state information
|
||||
// to restore the scene back to its current state.
|
||||
|
||||
Preferences.save()
|
||||
DraftsManager.save()
|
||||
try! scene.session.mastodonController?.persistentContainer.viewContext.save()
|
||||
}
|
||||
|
||||
private func handlePendingCrashReport(_ report: PLCrashReport, session: UISceneSession) {
|
||||
#if !DEBUG
|
||||
guard MFMailComposeViewController.canSendMail() else {
|
||||
print("Cannot send email")
|
||||
showAppOrOnboardingUI(session: session)
|
||||
return
|
||||
}
|
||||
|
||||
window!.rootViewController = CrashReporterViewController.create(report: report)
|
||||
#endif
|
||||
}
|
||||
|
||||
func showAppOrOnboardingUI(session: UISceneSession? = nil) {
|
||||
let session = session ?? window!.windowScene!.session
|
||||
if LocalData.shared.onboardingComplete {
|
||||
if session.mastodonController == nil {
|
||||
let account = LocalData.shared.getMostRecentAccount()!
|
||||
session.mastodonController = MastodonController.getForAccount(account)
|
||||
}
|
||||
|
||||
showAppUI()
|
||||
} else {
|
||||
showOnboardingUI()
|
||||
}
|
||||
}
|
||||
|
||||
func activateAccount(_ account: LocalData.UserAccountInfo) {
|
||||
|
@ -128,8 +157,17 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
mastodonController.getOwnAccount()
|
||||
mastodonController.getOwnInstance()
|
||||
|
||||
let tabBarController = MainTabBarViewController(mastodonController: mastodonController)
|
||||
window!.rootViewController = tabBarController
|
||||
let rootController: UIViewController
|
||||
#if SDK_IOS_14
|
||||
if #available(iOS 14.0, *) {
|
||||
rootController = MainSplitViewController(mastodonController: mastodonController)
|
||||
} else {
|
||||
rootController = MainTabBarViewController(mastodonController: mastodonController)
|
||||
}
|
||||
#else
|
||||
rootController = MainTabBarViewController(mastodonController: mastodonController)
|
||||
#endif
|
||||
window!.rootViewController = rootController
|
||||
}
|
||||
|
||||
func showOnboardingUI() {
|
||||
|
@ -149,3 +187,11 @@ extension SceneDelegate: OnboardingViewControllerDelegate {
|
|||
activateAccount(account)
|
||||
}
|
||||
}
|
||||
|
||||
extension SceneDelegate: MFMailComposeViewControllerDelegate {
|
||||
func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
|
||||
controller.dismiss(animated: true) {
|
||||
self.showAppOrOnboardingUI()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,7 +32,8 @@ class AccountListTableViewController: EnhancedTableViewController {
|
|||
|
||||
tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell)
|
||||
|
||||
tableView.rowHeight = 66
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 66
|
||||
|
||||
tableView.alwaysBounceVertical = true
|
||||
|
||||
|
|
|
@ -0,0 +1,215 @@
|
|||
//
|
||||
// AssetCollectionViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 1/1/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Photos
|
||||
|
||||
private let reuseIdentifier = "assetCell"
|
||||
private let cameraReuseIdentifier = "showCameraCell"
|
||||
|
||||
protocol AssetCollectionViewControllerDelegate: class {
|
||||
func shouldSelectAsset(_ asset: PHAsset) -> Bool
|
||||
func didSelectAssets(_ assets: [PHAsset])
|
||||
func captureFromCamera()
|
||||
}
|
||||
|
||||
class AssetCollectionViewController: UICollectionViewController {
|
||||
|
||||
weak var delegate: AssetCollectionViewControllerDelegate?
|
||||
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
private var flowLayout: UICollectionViewFlowLayout {
|
||||
return collectionViewLayout as! UICollectionViewFlowLayout
|
||||
}
|
||||
|
||||
private var availableWidth: CGFloat!
|
||||
private var thumbnailSize: CGSize!
|
||||
|
||||
private let imageManager = PHCachingImageManager()
|
||||
private var fetchResult: PHFetchResult<PHAsset>!
|
||||
|
||||
var selectedAssets: [PHAsset] {
|
||||
return collectionView.indexPathsForSelectedItems?.compactMap { (indexPath) in
|
||||
guard case let .asset(asset) = dataSource.itemIdentifier(for: indexPath) else { return nil }
|
||||
return asset
|
||||
} ?? []
|
||||
}
|
||||
|
||||
init() {
|
||||
super.init(collectionViewLayout: UICollectionViewFlowLayout())
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
// use the safe area layout guide instead of letting it automatically use the safe area insets
|
||||
// because otherwise, when presented in a popover with the arrow on the left or right side,
|
||||
// the collection view content will be cut off by the width of the arrow because the popover
|
||||
// doesn't respect safe area insets
|
||||
collectionView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
view.safeAreaLayoutGuide.leadingAnchor.constraint(equalTo: collectionView.leadingAnchor),
|
||||
view.safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: collectionView.trailingAnchor),
|
||||
// top ignores safe area because when presented in the sheet container, it simplifies the top content offset
|
||||
view.topAnchor.constraint(equalTo: collectionView.topAnchor),
|
||||
// bottom ignores safe area because we want cells to underflow bottom of the screen on notched iPhones
|
||||
view.bottomAnchor.constraint(equalTo: collectionView.bottomAnchor),
|
||||
])
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(donePressed))
|
||||
|
||||
collectionView.alwaysBounceVertical = true
|
||||
|
||||
collectionView.register(UINib(nibName: "AssetCollectionViewCell", bundle: .main), forCellWithReuseIdentifier: reuseIdentifier)
|
||||
collectionView.register(UINib(nibName: "ShowCameraCollectionViewCell", bundle: .main), forCellWithReuseIdentifier: cameraReuseIdentifier)
|
||||
|
||||
let scale = UIScreen.main.scale
|
||||
let cellSize = flowLayout.itemSize
|
||||
thumbnailSize = CGSize(width: cellSize.width * scale, height: cellSize.height * scale)
|
||||
|
||||
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView, cellProvider: { (collectionView, indexPath, item) -> UICollectionViewCell? in
|
||||
switch item {
|
||||
case .showCamera:
|
||||
return collectionView.dequeueReusableCell(withReuseIdentifier: cameraReuseIdentifier, for: indexPath)
|
||||
case let .asset(asset):
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! AssetCollectionViewCell
|
||||
|
||||
cell.updateUI(asset: asset)
|
||||
self.imageManager.requestImage(for: asset, targetSize: self.thumbnailSize, contentMode: .aspectFill, options: nil) { (image, _) in
|
||||
guard let image = image else { return }
|
||||
DispatchQueue.main.async {
|
||||
guard cell.assetIdentifier == asset.localIdentifier else { return }
|
||||
cell.thumbnailImage = image
|
||||
}
|
||||
}
|
||||
|
||||
return cell
|
||||
}
|
||||
})
|
||||
|
||||
let options = PHFetchOptions()
|
||||
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
|
||||
fetchResult = fetchAssets(with: options)
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.assets])
|
||||
var items: [Item] = [.showCamera]
|
||||
fetchResult.enumerateObjects { (asset, _, _) in
|
||||
items.append(.asset(asset))
|
||||
}
|
||||
snapshot.appendItems(items)
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
|
||||
collectionView.allowsMultipleSelection = true
|
||||
setEditing(true, animated: false)
|
||||
|
||||
updateItemsSelectedCount()
|
||||
|
||||
if let singleFingerPanGesture = collectionView.gestureRecognizers?.first(where: {
|
||||
$0.name == "multi-select.singleFingerPanGesture"
|
||||
}),
|
||||
let interactivePopGesture = navigationController?.interactivePopGestureRecognizer {
|
||||
singleFingerPanGesture.require(toFail: interactivePopGesture)
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillLayoutSubviews() {
|
||||
super.viewWillLayoutSubviews()
|
||||
|
||||
let availableWidth = view.bounds.inset(by: view.safeAreaInsets).width
|
||||
|
||||
if self.availableWidth != availableWidth {
|
||||
self.availableWidth = availableWidth
|
||||
|
||||
let size = (availableWidth - 8) / 3
|
||||
flowLayout.itemSize = CGSize(width: size, height: size)
|
||||
flowLayout.minimumInteritemSpacing = 4
|
||||
flowLayout.minimumLineSpacing = 4
|
||||
}
|
||||
}
|
||||
|
||||
open func fetchAssets(with options: PHFetchOptions) -> PHFetchResult<PHAsset> {
|
||||
return PHAsset.fetchAssets(with: options)
|
||||
}
|
||||
|
||||
func updateItemsSelectedCount() {
|
||||
let selected = collectionView.indexPathsForSelectedItems?.count ?? 0
|
||||
|
||||
navigationItem.title = "\(selected) selected"
|
||||
}
|
||||
|
||||
// MARK: UICollectionViewDelegate
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, shouldBeginMultipleSelectionInteractionAt indexPath: IndexPath) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return false }
|
||||
if let delegate = delegate,
|
||||
case let .asset(asset) = item {
|
||||
return delegate.shouldSelectAsset(asset)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
switch item {
|
||||
case .showCamera:
|
||||
collectionView.deselectItem(at: indexPath, animated: false)
|
||||
delegate?.captureFromCamera()
|
||||
case .asset(_):
|
||||
updateItemsSelectedCount()
|
||||
}
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
|
||||
updateItemsSelectedCount()
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
guard case let .asset(asset) = dataSource.itemIdentifier(for: indexPath) else { return nil }
|
||||
return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: { () -> UIViewController? in
|
||||
return AssetPreviewViewController(asset: asset)
|
||||
}, actionProvider: nil)
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
if let indexPath = (configuration.identifier as? NSIndexPath) as IndexPath?,
|
||||
let cell = collectionView.cellForItem(at: indexPath) as? AssetCollectionViewCell {
|
||||
let parameters = UIPreviewParameters()
|
||||
parameters.backgroundColor = .black
|
||||
return UITargetedPreview(view: cell.imageView, parameters: parameters)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc func donePressed() {
|
||||
delegate?.didSelectAssets(selectedAssets)
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension AssetCollectionViewController {
|
||||
enum Section: Hashable {
|
||||
case assets
|
||||
}
|
||||
enum Item: Hashable {
|
||||
case showCamera
|
||||
case asset(PHAsset)
|
||||
}
|
||||
}
|
|
@ -33,6 +33,8 @@ class AssetPickerSheetContainerViewController: SheetContainerViewController {
|
|||
|
||||
override func viewDidLoad() {
|
||||
assetPicker.view.layer.cornerRadius = view.bounds.width * 0.02
|
||||
// don't round bottom corners, since they'll always be cut off by the device
|
||||
assetPicker.view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||
|
||||
super.viewDidLoad()
|
||||
}
|
||||
|
@ -40,13 +42,6 @@ class AssetPickerSheetContainerViewController: SheetContainerViewController {
|
|||
}
|
||||
|
||||
extension AssetPickerSheetContainerViewController: SheetContainerViewControllerDelegate {
|
||||
func sheetContainer(_ sheetContainer: SheetContainerViewController, willSnapToDetent detent: Detent) -> Bool {
|
||||
if detent == .bottom {
|
||||
dismiss(animated: true)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func sheetContainerContentScrollView(_ sheetContainer: SheetContainerViewController) -> UIScrollView? {
|
||||
if let vc = assetPicker.visibleViewController as? UITableViewController {
|
||||
return vc.tableView
|
|
@ -9,16 +9,16 @@
|
|||
import UIKit
|
||||
import Photos
|
||||
|
||||
protocol AssetPickerViewControllerDelegate {
|
||||
func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachment.AttachmentType) -> Bool
|
||||
func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachment])
|
||||
protocol AssetPickerViewControllerDelegate: class {
|
||||
func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool
|
||||
func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachmentData])
|
||||
}
|
||||
|
||||
class AssetPickerViewController: UINavigationController {
|
||||
|
||||
var assetPickerDelegate: AssetPickerViewControllerDelegate?
|
||||
weak var assetPickerDelegate: AssetPickerViewControllerDelegate?
|
||||
|
||||
var currentCollectionSelectedAssets: [CompositionAttachment] {
|
||||
var currentCollectionSelectedAssets: [CompositionAttachmentData] {
|
||||
if let vc = visibleViewController as? AssetCollectionViewController {
|
||||
return vc.selectedAssets.map { .asset($0) }
|
||||
} else {
|
||||
|
@ -70,7 +70,7 @@ extension AssetPickerViewController: AssetCollectionViewControllerDelegate {
|
|||
|
||||
extension AssetPickerViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
|
||||
let attachment: CompositionAttachment
|
||||
let attachment: CompositionAttachmentData
|
||||
if let image = info[.originalImage] as? UIImage {
|
||||
attachment = .image(image)
|
||||
} else if let url = info[.mediaURL] as? URL {
|
|
@ -13,14 +13,18 @@ import AVKit
|
|||
|
||||
class AssetPreviewViewController: UIViewController {
|
||||
|
||||
let asset: PHAsset
|
||||
let attachment: CompositionAttachmentData
|
||||
|
||||
init(asset: PHAsset) {
|
||||
self.asset = asset
|
||||
init(attachment: CompositionAttachmentData) {
|
||||
self.attachment = attachment
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
convenience init(asset: PHAsset) {
|
||||
self.init(attachment: .asset(asset))
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
@ -30,21 +34,32 @@ class AssetPreviewViewController: UIViewController {
|
|||
|
||||
view.backgroundColor = .black
|
||||
|
||||
if asset.mediaType == .image {
|
||||
switch attachment {
|
||||
case let .image(image):
|
||||
showImage(image)
|
||||
case let .video(url):
|
||||
showVideo(asset: AVURLAsset(url: url))
|
||||
case let .asset(asset):
|
||||
switch asset.mediaType {
|
||||
case .image:
|
||||
if asset.mediaSubtypes.contains(.photoLive) {
|
||||
showLivePhoto()
|
||||
showLivePhoto(asset)
|
||||
} else {
|
||||
showImage()
|
||||
showAssetImage(asset)
|
||||
}
|
||||
} else if asset.mediaType == .video {
|
||||
playVideo()
|
||||
} else {
|
||||
case .video:
|
||||
showAssetVideo(asset)
|
||||
default:
|
||||
fatalError("asset mediaType must be image or video")
|
||||
}
|
||||
case let .drawing(drawing):
|
||||
let image = drawing.imageInLightMode(from: drawing.bounds)
|
||||
showImage(image)
|
||||
}
|
||||
}
|
||||
|
||||
func showImage() {
|
||||
let imageView = UIImageView()
|
||||
func showImage(_ image: UIImage) {
|
||||
let imageView = UIImageView(image: image)
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
view.addSubview(imageView)
|
||||
|
@ -54,7 +69,10 @@ class AssetPreviewViewController: UIViewController {
|
|||
imageView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
imageView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||
])
|
||||
preferredContentSize = image.size
|
||||
}
|
||||
|
||||
func showAssetImage(_ asset: PHAsset) {
|
||||
let options = PHImageRequestOptions()
|
||||
options.version = .current
|
||||
options.deliveryMode = .opportunistic
|
||||
|
@ -62,12 +80,12 @@ class AssetPreviewViewController: UIViewController {
|
|||
options.isNetworkAccessAllowed = true
|
||||
PHImageManager.default().requestImage(for: asset, targetSize: view.bounds.size, contentMode: .aspectFit, options: options) { (image, _) in
|
||||
DispatchQueue.main.async {
|
||||
imageView.image = image
|
||||
self.showImage(image!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func showLivePhoto() {
|
||||
func showLivePhoto(_ asset: PHAsset) {
|
||||
let options = PHLivePhotoRequestOptions()
|
||||
options.deliveryMode = .opportunistic
|
||||
options.version = .current
|
||||
|
@ -90,11 +108,23 @@ class AssetPreviewViewController: UIViewController {
|
|||
livePhotoView.topAnchor.constraint(equalTo: self.view.topAnchor),
|
||||
livePhotoView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor)
|
||||
])
|
||||
self.preferredContentSize = livePhoto.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func playVideo() {
|
||||
func showVideo(asset: AVAsset) {
|
||||
let playerController = AVPlayerViewController()
|
||||
let item = AVPlayerItem(asset: asset)
|
||||
let player = AVPlayer(playerItem: item)
|
||||
player.isMuted = true
|
||||
player.play()
|
||||
playerController.player = player
|
||||
self.embedChild(playerController)
|
||||
self.preferredContentSize = item.presentationSize
|
||||
}
|
||||
|
||||
func showAssetVideo(_ asset: PHAsset) {
|
||||
let options = PHVideoRequestOptions()
|
||||
options.deliveryMode = .automatic
|
||||
options.isNetworkAccessAllowed = true
|
||||
|
@ -104,13 +134,7 @@ class AssetPreviewViewController: UIViewController {
|
|||
fatalError("failed to get AVAsset")
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
let playerController = AVPlayerViewController()
|
||||
let item = AVPlayerItem(asset: avAsset)
|
||||
let player = AVPlayer(playerItem: item)
|
||||
player.isMuted = true
|
||||
player.play()
|
||||
playerController.player = player
|
||||
self.embedChild(playerController)
|
||||
self.showVideo(asset: avAsset)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
//
|
||||
// AttachmentPreviewViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 3/20/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import Gifu
|
||||
|
||||
class AttachmentPreviewViewController: UIViewController {
|
||||
|
||||
let attachment: Attachment
|
||||
|
||||
init(attachment: Attachment) {
|
||||
self.attachment = attachment
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func loadView() {
|
||||
if let data = ImageCache.attachments.get(attachment.url),
|
||||
let image = UIImage(data: data) {
|
||||
let imageView: UIImageView
|
||||
if attachment.url.pathExtension == "gif" {
|
||||
let gifView = GIFImageView(image: image)
|
||||
gifView.animate(withGIFData: data)
|
||||
imageView = gifView
|
||||
} else {
|
||||
imageView = UIImageView(image: image)
|
||||
}
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.backgroundColor = .black
|
||||
view = imageView
|
||||
preferredContentSize = image.size
|
||||
} else {
|
||||
view = UIActivityIndicatorView(style: .large)
|
||||
}
|
||||
}
|
||||
}
|