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"]
|
[submodule "Cache"]
|
||||||
path = Cache
|
path = Cache
|
||||||
url = git@github.com:hyperoslo/Cache.git
|
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
|
public var timeoutInterval: TimeInterval = 60
|
||||||
|
|
||||||
lazy var decoder: JSONDecoder = {
|
static let decoder: JSONDecoder = {
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
|
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
|
||||||
|
@ -36,6 +36,16 @@ public class Client {
|
||||||
return decoder
|
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) {
|
public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
|
||||||
self.baseURL = baseURL
|
self.baseURL = baseURL
|
||||||
self.accessToken = accessToken
|
self.accessToken = accessToken
|
||||||
|
@ -50,29 +60,24 @@ public class Client {
|
||||||
|
|
||||||
let task = session.dataTask(with: request) { data, response, error in
|
let task = session.dataTask(with: request) { data, response, error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
completion(.failure(error))
|
completion(.failure(.networkError(error)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let data = data,
|
guard let data = data,
|
||||||
let response = response as? HTTPURLResponse else {
|
let response = response as? HTTPURLResponse else {
|
||||||
completion(.failure(Error.invalidResponse))
|
completion(.failure(.invalidResponse))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard response.statusCode == 200 else {
|
guard response.statusCode == 200 else {
|
||||||
let mastodonError = try? self.decoder.decode(MastodonError.self, from: data)
|
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
|
||||||
let error = mastodonError.flatMap { Error.mastodonError($0.description) } ?? Error.unknownError
|
let error: Error = mastodonError.flatMap { .mastodonError($0.description) } ?? .unexpectedStatus(response.statusCode)
|
||||||
completion(.failure(error))
|
completion(.failure(error))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let result = try? self.decoder.decode(Result.self, from: data) else {
|
guard let result = try? Client.decoder.decode(Result.self, from: data) else {
|
||||||
completion(.failure(Error.invalidModel))
|
completion(.failure(.invalidModel))
|
||||||
return
|
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)
|
let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
|
||||||
|
|
||||||
completion(.success(result, pagination))
|
completion(.success(result, pagination))
|
||||||
|
@ -97,7 +102,7 @@ public class Client {
|
||||||
|
|
||||||
// MARK: - Authorization
|
// MARK: - Authorization
|
||||||
public func registerApp(name: String, redirectURI: String, scopes: [Scope], website: URL? = nil, completion: @escaping Callback<RegisteredApplication>) {
|
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,
|
"client_name" => name,
|
||||||
"redirect_uris" => redirectURI,
|
"redirect_uris" => redirectURI,
|
||||||
"scopes" => scopes.scopeString,
|
"scopes" => scopes.scopeString,
|
||||||
|
@ -114,7 +119,7 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) {
|
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_id" => clientID,
|
||||||
"client_secret" => clientSecret,
|
"client_secret" => clientSecret,
|
||||||
"grant_type" => "authorization_code",
|
"grant_type" => "authorization_code",
|
||||||
|
@ -173,13 +178,13 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func block(domain: String) -> Request<Empty> {
|
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
|
"domain" => domain
|
||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unblock(domain: String) -> Request<Empty> {
|
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
|
"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> {
|
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,
|
"phrase" => phrase,
|
||||||
"irreversible" => irreversible,
|
"irreversible" => irreversible,
|
||||||
"whole_word" => wholeWord,
|
"whole_word" => wholeWord,
|
||||||
|
@ -214,7 +219,7 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func followRemote(acct: String) -> Request<Account> {
|
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
|
// MARK: - Lists
|
||||||
|
@ -227,12 +232,12 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func createList(title: String) -> Request<List> {
|
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
|
// MARK: - Media
|
||||||
public static func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil) -> Request<Attachment> {
|
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,
|
"description" => description,
|
||||||
"focus" => focus
|
"focus" => focus
|
||||||
], attachment))
|
], attachment))
|
||||||
|
@ -264,7 +269,7 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func report(account: Account, statuses: [Status], comment: String) -> Request<Report> {
|
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,
|
"account_id" => account.id,
|
||||||
"comment" => comment
|
"comment" => comment
|
||||||
] + "status_ids" => statuses.map { $0.id }))
|
] + "status_ids" => statuses.map { $0.id }))
|
||||||
|
@ -292,7 +297,7 @@ public class Client {
|
||||||
spoilerText: String? = nil,
|
spoilerText: String? = nil,
|
||||||
visibility: Status.Visibility? = nil,
|
visibility: Status.Visibility? = nil,
|
||||||
language: String? = nil) -> Request<Status> {
|
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,
|
"status" => text,
|
||||||
"content_type" => contentType.mimeType,
|
"content_type" => contentType.mimeType,
|
||||||
"in_reply_to_id" => inReplyTo,
|
"in_reply_to_id" => inReplyTo,
|
||||||
|
@ -319,12 +324,32 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Client {
|
extension Client {
|
||||||
public enum Error: Swift.Error {
|
public enum Error: LocalizedError {
|
||||||
case unknownError
|
case networkError(Swift.Error)
|
||||||
|
case unexpectedStatus(Int)
|
||||||
case invalidRequest
|
case invalidRequest
|
||||||
case invalidResponse
|
case invalidResponse
|
||||||
case invalidModel
|
case invalidModel
|
||||||
case mastodonError(String)
|
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
|
import Foundation
|
||||||
|
|
||||||
public class Account: Decodable {
|
public final class Account: AccountProtocol, Decodable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let username: String
|
public let username: String
|
||||||
public let acct: String
|
public let acct: String
|
||||||
|
@ -27,7 +27,7 @@ public class Account: Decodable {
|
||||||
public private(set) var emojis: [Emoji]
|
public private(set) var emojis: [Emoji]
|
||||||
public let moved: Bool?
|
public let moved: Bool?
|
||||||
public let movedTo: Account?
|
public let movedTo: Account?
|
||||||
public let fields: [Field]?
|
public let fields: [Field]
|
||||||
public let bot: Bool?
|
public let bot: Bool?
|
||||||
|
|
||||||
public required init(from decoder: Decoder) throws {
|
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.avatar = try container.decode(URL.self, forKey: .avatar)
|
||||||
self.avatarStatic = try container.decode(URL.self, forKey: .avatarStatic)
|
self.avatarStatic = try container.decode(URL.self, forKey: .avatarStatic)
|
||||||
self.header = try container.decode(URL.self, forKey: .header)
|
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.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)
|
self.bot = try? container.decode(Bool.self, forKey: .bot)
|
||||||
|
|
||||||
if let moved = try? container.decode(Bool.self, forKey: .moved) {
|
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> {
|
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
|
"notifications" => notifications
|
||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,18 +8,19 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class Attachment: Decodable {
|
public class Attachment: Codable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let kind: Kind
|
public let kind: Kind
|
||||||
public let url: URL
|
public let url: URL
|
||||||
public let remoteURL: URL?
|
public let remoteURL: URL?
|
||||||
public let previewURL: URL
|
public let previewURL: URL?
|
||||||
public let textURL: URL?
|
public let textURL: URL?
|
||||||
public let meta: Metadata?
|
public let meta: Metadata?
|
||||||
public let description: String?
|
public let description: String?
|
||||||
|
public let blurHash: String?
|
||||||
|
|
||||||
public static func update(_ attachment: Attachment, focus: (Float, Float)?, description: String?) -> Request<Attachment> {
|
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),
|
"description" => (description ?? attachment.description),
|
||||||
"focus" => focus
|
"focus" => focus
|
||||||
], nil))
|
], nil))
|
||||||
|
@ -29,20 +30,13 @@ public class Attachment: Decodable {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.id = try container.decode(String.self, forKey: .id)
|
self.id = try container.decode(String.self, forKey: .id)
|
||||||
self.kind = try container.decode(Kind.self, forKey: .kind)
|
self.kind = try container.decode(Kind.self, forKey: .kind)
|
||||||
self.url = URL(lenient: try container.decode(String.self, forKey: .url))!
|
self.url = try container.decode(URL.self, forKey: .url)
|
||||||
if let remote = try? container.decode(String.self, forKey: .remoteURL) {
|
self.previewURL = try? container.decode(URL?.self, forKey: .previewURL)
|
||||||
self.remoteURL = URL(lenient: remote.replacingOccurrences(of: " ", with: "%20"))
|
self.remoteURL = try? container.decode(URL?.self, forKey: .remoteURL)
|
||||||
} else {
|
self.textURL = try? container.decode(URL?.self, forKey: .textURL)
|
||||||
self.remoteURL = nil
|
self.meta = try? container.decode(Metadata?.self, forKey: .meta)
|
||||||
}
|
self.description = try? container.decode(String?.self, forKey: .description)
|
||||||
self.previewURL = URL(lenient: try container.decode(String.self, forKey: .previewURL).replacingOccurrences(of: " ", with: "%20"))!
|
self.blurHash = try? container.decode(String?.self, forKey: .blurHash)
|
||||||
if let text = try? container.decode(String.self, forKey: .textURL) {
|
|
||||||
self.textURL = URL(lenient: text.replacingOccurrences(of: " ", with: "%20"))
|
|
||||||
} else {
|
|
||||||
self.textURL = nil
|
|
||||||
}
|
|
||||||
self.meta = try? container.decode(Metadata.self, forKey: .meta)
|
|
||||||
self.description = try? container.decode(String.self, forKey: .description)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
@ -54,11 +48,12 @@ public class Attachment: Decodable {
|
||||||
case textURL = "text_url"
|
case textURL = "text_url"
|
||||||
case meta
|
case meta
|
||||||
case description
|
case description
|
||||||
|
case blurHash = "blurhash"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Attachment {
|
extension Attachment {
|
||||||
public enum Kind: String, Decodable {
|
public enum Kind: String, Codable {
|
||||||
case image
|
case image
|
||||||
case video
|
case video
|
||||||
case gifv
|
case gifv
|
||||||
|
@ -68,7 +63,7 @@ extension Attachment {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Attachment {
|
extension Attachment {
|
||||||
public class Metadata: Decodable {
|
public struct Metadata: Codable {
|
||||||
public let length: String?
|
public let length: String?
|
||||||
public let duration: Float?
|
public let duration: Float?
|
||||||
public let audioEncoding: String?
|
public let audioEncoding: String?
|
||||||
|
@ -99,7 +94,7 @@ extension Attachment {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ImageMetadata: Decodable {
|
public struct ImageMetadata: Codable {
|
||||||
public let width: Int?
|
public let width: Int?
|
||||||
public let height: Int?
|
public let height: Int?
|
||||||
public let size: String?
|
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 width: Int?
|
||||||
public let height: 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 {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case url
|
case url
|
||||||
case title
|
case title
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class Emoji: Decodable {
|
public class Emoji: Codable {
|
||||||
public let shortcode: String
|
public let shortcode: String
|
||||||
public let url: URL
|
public let url: URL
|
||||||
public let staticURL: 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> {
|
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),
|
"phrase" => (phrase ?? filter.phrase),
|
||||||
"irreversible" => (irreversible ?? filter.irreversible),
|
"irreversible" => (irreversible ?? filter.irreversible),
|
||||||
"whole_word" => (wholeWord ?? filter.wholeWord),
|
"whole_word" => (wholeWord ?? filter.wholeWord),
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class List: Decodable {
|
public class List: Decodable, Equatable, Hashable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let title: String
|
public let title: String
|
||||||
|
|
||||||
|
@ -16,6 +16,14 @@ public class List: Decodable {
|
||||||
return .list(id: id)
|
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]> {
|
public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
|
||||||
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts")
|
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts")
|
||||||
request.range = range
|
request.range = range
|
||||||
|
@ -23,7 +31,7 @@ public class List: Decodable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func update(_ list: List, title: String) -> Request<List> {
|
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> {
|
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> {
|
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
|
"account_ids" => accountIDs
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func remove(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
|
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
|
"account_ids" => accountIDs
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class Mention: Decodable {
|
public class Mention: Codable {
|
||||||
public let url: URL
|
public let url: URL
|
||||||
public let username: String
|
public let username: String
|
||||||
public let acct: String
|
public let acct: String
|
||||||
|
|
|
@ -15,8 +15,26 @@ public class Notification: Decodable {
|
||||||
public let account: Account
|
public let account: Account
|
||||||
public let status: Status?
|
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> {
|
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
|
"id" => notificationID
|
||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
|
@ -37,6 +55,7 @@ extension Notification {
|
||||||
case favourite
|
case favourite
|
||||||
case follow
|
case follow
|
||||||
case followRequest = "follow_request"
|
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
|
import Foundation
|
||||||
|
|
||||||
public class Status: Decodable {
|
public final class Status: /*StatusProtocol,*/ Decodable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let uri: String
|
public let uri: String
|
||||||
public let url: URL?
|
public let url: URL?
|
||||||
|
@ -36,23 +36,26 @@ public class Status: Decodable {
|
||||||
public let language: String?
|
public let language: String?
|
||||||
public let pinned: Bool?
|
public let pinned: Bool?
|
||||||
public let bookmarked: Bool?
|
public let bookmarked: Bool?
|
||||||
|
public let card: Card?
|
||||||
|
|
||||||
public static func getContext(_ status: Status) -> Request<ConversationContext> {
|
public var applicationName: String? { application?.name }
|
||||||
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(status.id)/context")
|
|
||||||
|
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> {
|
public static func getCard(_ status: Status) -> Request<Card> {
|
||||||
return Request<Card>(method: .get, path: "/api/v1/statuses/\(status.id)/card")
|
return Request<Card>(method: .get, path: "/api/v1/statuses/\(status.id)/card")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getFavourites(_ status: Status, range: RequestRange = .default) -> Request<[Account]> {
|
public static func getFavourites(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||||
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/favourited_by")
|
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/favourited_by")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getReblogs(_ status: Status, range: RequestRange = .default) -> Request<[Account]> {
|
public static func getReblogs(_ statusID: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||||
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(status.id)/reblogged_by")
|
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/reblogged_by")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
@ -61,44 +64,44 @@ public class Status: Decodable {
|
||||||
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(status.id)")
|
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(status.id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func reblog(_ status: Status) -> Request<Status> {
|
public static func reblog(_ statusID: String) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/reblog")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/reblog")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unreblog(_ status: Status) -> Request<Status> {
|
public static func unreblog(_ statusID: String) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unreblog")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unreblog")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func favourite(_ status: Status) -> Request<Status> {
|
public static func favourite(_ statusID: String) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/favourite")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/favourite")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unfavourite(_ status: Status) -> Request<Status> {
|
public static func unfavourite(_ statusID: String) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unfavourite")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unfavourite")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func pin(_ status: Status) -> Request<Status> {
|
public static func pin(_ statusID: String) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/pin")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/pin")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unpin(_ status: Status) -> Request<Status> {
|
public static func unpin(_ statusID: String) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unpin")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unpin")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func bookmark(_ status: Status) -> Request<Status> {
|
public static func bookmark(_ statusID: String) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/bookmark")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/bookmark")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unbookmark(_ status: Status) -> Request<Status> {
|
public static func unbookmark(_ statusID: String) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unbookmark")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unbookmark")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func muteConversation(_ status: Status) -> Request<Status> {
|
public static func muteConversation(_ statusID: String) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/mute")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/mute")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unmuteConversation(_ status: Status) -> Request<Status> {
|
public static func unmuteConversation(_ statusID: String) -> Request<Status> {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(status.id)/unmute")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unmute")
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
@ -128,6 +131,7 @@ public class Status: Decodable {
|
||||||
case language
|
case language
|
||||||
case pinned
|
case pinned
|
||||||
case bookmarked
|
case bookmarked
|
||||||
|
case card
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,56 +8,82 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum Body {
|
protocol Body {
|
||||||
case parameters([Parameter]?)
|
var mimeType: String? { get }
|
||||||
case formData([Parameter]?, FormAttachment?)
|
var data: Data? { get }
|
||||||
case empty
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Body {
|
struct EmptyBody: Body {
|
||||||
private static let boundary: String = "PachydermBoundary"
|
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? {
|
var data: Data? {
|
||||||
switch self {
|
|
||||||
case let .parameters(parameters):
|
|
||||||
return parameters?.urlEncoded.data(using: .utf8)
|
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()
|
var data = Data()
|
||||||
parameters?.forEach { param in
|
parameters?.forEach { param in
|
||||||
guard let value = param.value else { return }
|
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("Content-Disposition: form-data; name=\"\(param.name)\"\r\n\r\n")
|
||||||
data.append("\(value)\r\n")
|
data.append("\(value)\r\n")
|
||||||
}
|
}
|
||||||
if let attachment = attachment {
|
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-Disposition: form-data; name=\"file\"; filename=\"\(attachment.fileName)\"\r\n")
|
||||||
data.append("Content-Type: \(attachment.mimeType)\r\n\r\n")
|
data.append("Content-Type: \(attachment.mimeType)\r\n\r\n")
|
||||||
data.append(attachment.data)
|
data.append(attachment.data)
|
||||||
data.append("\r\n")
|
data.append("\r\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
data.append("--\(Body.boundary)--\r\n")
|
data.append("--\(FormDataBody.boundary)--\r\n")
|
||||||
return data
|
return data
|
||||||
case .empty:
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var mimeType: String? {
|
struct JsonBody<T: Encodable>: Body {
|
||||||
switch self {
|
let value: T
|
||||||
case let .parameters(parameters):
|
|
||||||
if parameters == nil {
|
init(_ value: T) {
|
||||||
return nil
|
self.value = value
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
let body: Body
|
||||||
var queryParameters: [Parameter]
|
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.method = method
|
||||||
self.path = path
|
self.path = path
|
||||||
self.body = body
|
self.body = body
|
||||||
|
|
|
@ -10,5 +10,5 @@ import Foundation
|
||||||
|
|
||||||
public enum Response<Result: Decodable> {
|
public enum Response<Result: Decodable> {
|
||||||
case success(Result, Pagination?)
|
case success(Result, Pagination?)
|
||||||
case failure(Error)
|
case failure(Client.Error)
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,16 +22,16 @@ public class InstanceSelector {
|
||||||
let request = URLRequest(url: url)
|
let request = URLRequest(url: url)
|
||||||
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
|
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
completion(.failure(error))
|
completion(.failure(.networkError(error)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let data = data,
|
guard let data = data,
|
||||||
let response = response as? HTTPURLResponse else {
|
let response = response as? HTTPURLResponse else {
|
||||||
completion(.failure(Client.Error.invalidResponse))
|
completion(.failure(.invalidResponse))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard response.statusCode == 200 else {
|
guard response.statusCode == 200 else {
|
||||||
completion(.failure(Client.Error.unknownError))
|
completion(.failure(.unexpectedStatus(response.statusCode)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let result = try? decoder.decode([Instance].self, from: data) else {
|
guard let result = try? decoder.decode([Instance].self, from: data) else {
|
||||||
|
|
|
@ -9,14 +9,14 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class NotificationGroup {
|
public class NotificationGroup {
|
||||||
public let notificationIDs: [String]
|
public let notifications: [Notification]
|
||||||
public let id: String
|
public let id: String
|
||||||
public let kind: Notification.Kind
|
public let kind: Notification.Kind
|
||||||
public let statusState: StatusState?
|
public let statusState: StatusState?
|
||||||
|
|
||||||
init?(notifications: [Notification]) {
|
init?(notifications: [Notification]) {
|
||||||
guard !notifications.isEmpty else { return nil }
|
guard !notifications.isEmpty else { return nil }
|
||||||
self.notificationIDs = notifications.map { $0.id }
|
self.notifications = notifications
|
||||||
self.id = notifications.first!.id
|
self.id = notifications.first!.id
|
||||||
self.kind = notifications.first!.kind
|
self.kind = notifications.first!.kind
|
||||||
if kind == .mention {
|
if kind == .mention {
|
||||||
|
@ -27,18 +27,24 @@ public class NotificationGroup {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
||||||
return notifications.reduce(into: [[Notification]]()) { (groups, notification) in
|
var groups = [[Notification]]()
|
||||||
if allowedTypes.contains(notification.kind),
|
for notification in notifications {
|
||||||
let lastGroup = groups.last,
|
if allowedTypes.contains(notification.kind) {
|
||||||
let firstStatus = lastGroup.first,
|
if let lastGroup = groups.last, let firstNotification = lastGroup.first, firstNotification.kind == notification.kind, firstNotification.status?.id == notification.status?.id {
|
||||||
firstStatus.kind == notification.kind,
|
|
||||||
firstStatus.status?.id == notification.status?.id {
|
|
||||||
|
|
||||||
groups[groups.count - 1].append(notification)
|
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])
|
groups.append([notification])
|
||||||
}
|
}
|
||||||
}.map {
|
return groups.map {
|
||||||
NotificationGroup(notifications: $0)!
|
NotificationGroup(notifications: $0)!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
Subproject commit f445c9067d28346e828e615e2b43cb07b20bca35
|
|
|
@ -27,15 +27,6 @@
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
<MacroExpansion>
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "D6D4DDCB212518A000E1C4BB"
|
|
||||||
BuildableName = "Tusker.app"
|
|
||||||
BlueprintName = "Tusker"
|
|
||||||
ReferencedContainer = "container:Tusker.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</MacroExpansion>
|
|
||||||
<Testables>
|
<Testables>
|
||||||
<TestableReference
|
<TestableReference
|
||||||
skipped = "NO">
|
skipped = "NO">
|
||||||
|
@ -92,6 +83,12 @@
|
||||||
ReferencedContainer = "container:Tusker.xcodeproj">
|
ReferencedContainer = "container:Tusker.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
<CommandLineArgument
|
||||||
|
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</CommandLineArgument>
|
||||||
|
</CommandLineArguments>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
|
|
|
@ -10,9 +10,6 @@
|
||||||
<FileRef
|
<FileRef
|
||||||
location = "group:Cache/Cache.xcodeproj">
|
location = "group:Cache/Cache.xcodeproj">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
<FileRef
|
|
||||||
location = "group:SwiftSoup/SwiftSoup.xcodeproj">
|
|
||||||
</FileRef>
|
|
||||||
<FileRef
|
<FileRef
|
||||||
location = "group:Gifu/Gifu.xcodeproj">
|
location = "group:Gifu/Gifu.xcodeproj">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
|
|
|
@ -1,14 +1,32 @@
|
||||||
{
|
{
|
||||||
"object": {
|
"object": {
|
||||||
"pins": [
|
"pins": [
|
||||||
|
{
|
||||||
|
"package": "PLCrashReporter",
|
||||||
|
"repositoryURL": "https://github.com/microsoft/plcrashreporter",
|
||||||
|
"state": {
|
||||||
|
"branch": null,
|
||||||
|
"revision": "6b7ca9a2faad6ea990ff60b0a3ee4fdf3db59150",
|
||||||
|
"version": "1.7.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"package": "SheetController",
|
"package": "SheetController",
|
||||||
"repositoryURL": "https://git.shadowfacts.net/shadowfacts/SheetController.git",
|
"repositoryURL": "https://git.shadowfacts.net/shadowfacts/SheetController.git",
|
||||||
"state": {
|
"state": {
|
||||||
"branch": "master",
|
"branch": "master",
|
||||||
"revision": "6ee1ad24ec8620f5c17416d6141643f0787708ba",
|
"revision": "aa0f5192eaf19d01c89dbfa9ec5878a700376f23",
|
||||||
"version": null
|
"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 UIKit
|
||||||
import Pachyderm
|
|
||||||
|
|
||||||
class AccountActivity: MastodonActivity {
|
class AccountActivity: MastodonActivity {
|
||||||
|
|
||||||
|
@ -15,17 +14,17 @@ class AccountActivity: MastodonActivity {
|
||||||
return .action
|
return .action
|
||||||
}
|
}
|
||||||
|
|
||||||
var account: Account?
|
var account: AccountMO?
|
||||||
|
|
||||||
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
|
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
|
||||||
for case is Account in activityItems {
|
for case is AccountMO in activityItems {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override func prepare(withActivityItems activityItems: [Any]) {
|
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
|
self.account = account
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,9 +29,7 @@ class FollowAccountActivity: AccountActivity {
|
||||||
|
|
||||||
let request = Account.follow(account.id)
|
let request = Account.follow(account.id)
|
||||||
mastodonController.run(request) { (response) in
|
mastodonController.run(request) { (response) in
|
||||||
if case let .success(relationship, _) = response {
|
if case .failure(_) = response {
|
||||||
self.mastodonController.cache.add(relationship: relationship)
|
|
||||||
} else {
|
|
||||||
// todo: display error message
|
// todo: display error message
|
||||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||||
fatalError()
|
fatalError()
|
||||||
|
|
|
@ -28,7 +28,9 @@ class SendMessageActivity: AccountActivity {
|
||||||
override var activityViewController: UIViewController? {
|
override var activityViewController: UIViewController? {
|
||||||
guard let account = account else { return nil }
|
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)
|
let request = Account.unfollow(account.id)
|
||||||
mastodonController.run(request) { (response) in
|
mastodonController.run(request) { (response) in
|
||||||
if case let .success(relationship, _) = response {
|
if case .failure(_) = response {
|
||||||
self.mastodonController.cache.add(relationship: relationship)
|
|
||||||
} else {
|
|
||||||
// todo: display error message
|
// todo: display error message
|
||||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||||
fatalError()
|
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)
|
activityDidFinish(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func completionHandler(viewController: UIViewController, url: URL) -> UIActivityViewController.CompletionWithItemsHandler {
|
static func completionHandler(navigator: TuskerNavigationDelegate, url: URL) -> UIActivityViewController.CompletionWithItemsHandler {
|
||||||
return { (activityType, _, _, _) in
|
return { (activityType, _, _, _) in
|
||||||
if activityType == .openInSafari {
|
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() {
|
override func perform() {
|
||||||
guard let status = status else { return }
|
guard let status = status else { return }
|
||||||
|
|
||||||
let request = Status.bookmark(status)
|
let request = Status.bookmark(status.id)
|
||||||
mastodonController.run(request) { (response) in
|
mastodonController.run(request) { (response) in
|
||||||
if case let .success(status, _) = response {
|
if case let .success(status, _) = response {
|
||||||
self.mastodonController.cache.add(status: status)
|
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
||||||
} else {
|
} else {
|
||||||
// todo: display error message
|
// todo: display error message
|
||||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
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() {
|
override func perform() {
|
||||||
guard let status = status else { return }
|
guard let status = status else { return }
|
||||||
|
|
||||||
let request = Status.pin(status)
|
let request = Status.pin(status.id)
|
||||||
mastodonController.run(request) { (response) in
|
mastodonController.run(request) { (response) in
|
||||||
if case let .success(status, _) = response {
|
if case let .success(status, _) = response {
|
||||||
self.mastodonController.cache.add(status: status)
|
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
||||||
} else {
|
} else {
|
||||||
// todo: display error message
|
// todo: display error message
|
||||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||||
|
|
|
@ -7,7 +7,6 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
|
||||||
|
|
||||||
class StatusActivity: MastodonActivity {
|
class StatusActivity: MastodonActivity {
|
||||||
|
|
||||||
|
@ -15,17 +14,17 @@ class StatusActivity: MastodonActivity {
|
||||||
return .action
|
return .action
|
||||||
}
|
}
|
||||||
|
|
||||||
var status: Status?
|
var status: StatusMO?
|
||||||
|
|
||||||
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
|
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
|
||||||
for case is Status in activityItems {
|
for case is StatusMO in activityItems {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override func prepare(withActivityItems activityItems: [Any]) {
|
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
|
self.status = status
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,10 +26,10 @@ class UnbookmarkStatusActivity: StatusActivity {
|
||||||
override func perform() {
|
override func perform() {
|
||||||
guard let status = status else { return }
|
guard let status = status else { return }
|
||||||
|
|
||||||
let request = Status.unbookmark(status)
|
let request = Status.unbookmark(status.id)
|
||||||
mastodonController.run(request) { (response) in
|
mastodonController.run(request) { (response) in
|
||||||
if case let .success(status, _) = response {
|
if case let .success(status, _) = response {
|
||||||
self.mastodonController.cache.add(status: status)
|
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
||||||
} else {
|
} else {
|
||||||
// todo: display error message
|
// todo: display error message
|
||||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
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() {
|
override func perform() {
|
||||||
guard let status = status else { return }
|
guard let status = status else { return }
|
||||||
|
|
||||||
let request = Status.unpin(status)
|
let request = Status.unpin(status.id)
|
||||||
mastodonController.run(request) { (response) in
|
mastodonController.run(request) { (response) in
|
||||||
if case let .success(status, _) = response {
|
if case let .success(status, _) = response {
|
||||||
self.mastodonController.cache.add(status: status)
|
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
|
||||||
} else {
|
} else {
|
||||||
// todo: display error message
|
// todo: display error message
|
||||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
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 unbookmarkStatus = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).unbookmark_status")
|
||||||
static let pinStatus = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).pin_status")
|
static let pinStatus = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).pin_status")
|
||||||
static let unpinStatus = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).unpin_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 UIKit
|
||||||
|
import CrashReporter
|
||||||
|
|
||||||
@UIApplicationMain
|
@UIApplicationMain
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
|
static private(set) var crashReporter: PLCrashReporter!
|
||||||
|
static var pendingCrashReport: PLCrashReport?
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
|
#if !DEBUG
|
||||||
|
setupCrashReporter()
|
||||||
|
#endif
|
||||||
|
|
||||||
AppShortcutItem.createItems(for: application)
|
AppShortcutItem.createItems(for: application)
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
AudioSessionHelper.disable()
|
||||||
|
AudioSessionHelper.setDefault()
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
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" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "20x20@2x.png",
|
||||||
"idiom" : "iphone",
|
"idiom" : "iphone",
|
||||||
"size" : "20x20",
|
"scale" : "2x",
|
||||||
"scale" : "2x"
|
"size" : "20x20"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "20x20@3x.png",
|
||||||
"idiom" : "iphone",
|
"idiom" : "iphone",
|
||||||
"size" : "20x20",
|
"scale" : "3x",
|
||||||
"scale" : "3x"
|
"size" : "20x20"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "29x29@2x.png",
|
||||||
"idiom" : "iphone",
|
"idiom" : "iphone",
|
||||||
"size" : "29x29",
|
"scale" : "2x",
|
||||||
"scale" : "2x"
|
"size" : "29x29"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "29x29@3x.png",
|
||||||
"idiom" : "iphone",
|
"idiom" : "iphone",
|
||||||
"size" : "29x29",
|
"scale" : "3x",
|
||||||
"scale" : "3x"
|
"size" : "29x29"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "40x40@2x.png",
|
||||||
"idiom" : "iphone",
|
"idiom" : "iphone",
|
||||||
"size" : "40x40",
|
"scale" : "2x",
|
||||||
"scale" : "2x"
|
"size" : "40x40"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "40x40@3x.png",
|
||||||
"idiom" : "iphone",
|
"idiom" : "iphone",
|
||||||
"size" : "40x40",
|
"scale" : "3x",
|
||||||
"scale" : "3x"
|
"size" : "40x40"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "60x60@2x.png",
|
||||||
"idiom" : "iphone",
|
"idiom" : "iphone",
|
||||||
"size" : "60x60",
|
"scale" : "2x",
|
||||||
"scale" : "2x"
|
"size" : "60x60"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "60x60@3x.png",
|
||||||
"idiom" : "iphone",
|
"idiom" : "iphone",
|
||||||
"size" : "60x60",
|
"scale" : "3x",
|
||||||
"scale" : "3x"
|
"size" : "60x60"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "20x20@1x.png",
|
||||||
"idiom" : "ipad",
|
"idiom" : "ipad",
|
||||||
"size" : "20x20",
|
"scale" : "1x",
|
||||||
"scale" : "1x"
|
"size" : "20x20"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "20x20@2x-1.png",
|
||||||
"idiom" : "ipad",
|
"idiom" : "ipad",
|
||||||
"size" : "20x20",
|
"scale" : "2x",
|
||||||
"scale" : "2x"
|
"size" : "20x20"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "29x29@1x.png",
|
||||||
"idiom" : "ipad",
|
"idiom" : "ipad",
|
||||||
"size" : "29x29",
|
"scale" : "1x",
|
||||||
"scale" : "1x"
|
"size" : "29x29"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "29x29@2x-1.png",
|
||||||
"idiom" : "ipad",
|
"idiom" : "ipad",
|
||||||
"size" : "29x29",
|
"scale" : "2x",
|
||||||
"scale" : "2x"
|
"size" : "29x29"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "40x40@1x.png",
|
||||||
"idiom" : "ipad",
|
"idiom" : "ipad",
|
||||||
"size" : "40x40",
|
"scale" : "1x",
|
||||||
"scale" : "1x"
|
"size" : "40x40"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "40x40@2x-1.png",
|
||||||
"idiom" : "ipad",
|
"idiom" : "ipad",
|
||||||
"size" : "40x40",
|
"scale" : "2x",
|
||||||
"scale" : "2x"
|
"size" : "40x40"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "76x76@1x.png",
|
||||||
"idiom" : "ipad",
|
"idiom" : "ipad",
|
||||||
"size" : "76x76",
|
"scale" : "1x",
|
||||||
"scale" : "1x"
|
"size" : "76x76"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "76x76@2x.png",
|
||||||
"idiom" : "ipad",
|
"idiom" : "ipad",
|
||||||
"size" : "76x76",
|
"scale" : "2x",
|
||||||
"scale" : "2x"
|
"size" : "76x76"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "83.5x83.5@2x.png",
|
||||||
"idiom" : "ipad",
|
"idiom" : "ipad",
|
||||||
"size" : "83.5x83.5",
|
"scale" : "2x",
|
||||||
"scale" : "2x"
|
"size" : "83.5x83.5"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "1024x1024@1x.png",
|
||||||
"idiom" : "ios-marketing",
|
"idiom" : "ios-marketing",
|
||||||
"size" : "1024x1024",
|
"scale" : "1x",
|
||||||
"scale" : "1x"
|
"size" : "1024x1024"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"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)
|
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 attachments = ImageCache(name: "Attachments", memoryExpiry: .seconds(60 * 2))
|
||||||
static let emojis = ImageCache(name: "Emojis", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60))
|
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) {
|
init(name: String, memoryExpiry expiry: Expiry) {
|
||||||
let storage = MemoryStorage<Data>(config: MemoryConfig(expiry: expiry))
|
let storage = MemoryStorage<Data>(config: MemoryConfig(expiry: expiry))
|
||||||
|
@ -43,14 +43,18 @@ class ImageCache {
|
||||||
completion?(data)
|
completion?(data)
|
||||||
return nil
|
return nil
|
||||||
} else {
|
} else {
|
||||||
if let completion = completion, let group = requests[url] {
|
if let completion = completion, let group = groups[url] {
|
||||||
return group.addCallback(completion)
|
return group.addCallback(completion)
|
||||||
} else {
|
} else {
|
||||||
let group = RequestGroup(url: url)
|
let group = RequestGroup(url: url) { (data) in
|
||||||
let request = group.addCallback(completion)
|
if let data = data {
|
||||||
group.run { (data) in
|
|
||||||
try? self.cache.setObject(data, forKey: key)
|
try? self.cache.setObject(data, forKey: key)
|
||||||
}
|
}
|
||||||
|
self.groups.removeValue(forKey: url)
|
||||||
|
}
|
||||||
|
groups[url] = group
|
||||||
|
let request = group.addCallback(completion)
|
||||||
|
group.run()
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -61,29 +65,34 @@ class ImageCache {
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelWithoutCallback(_ url: URL) {
|
func cancelWithoutCallback(_ url: URL) {
|
||||||
requests[url]?.cancelWithoutCallback()
|
groups[url]?.cancelWithoutCallback()
|
||||||
}
|
}
|
||||||
|
|
||||||
class RequestGroup {
|
func reset() throws {
|
||||||
|
try cache.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RequestGroup {
|
||||||
let url: URL
|
let url: URL
|
||||||
|
private let onFinished: (Data?) -> Void
|
||||||
private var task: URLSessionDataTask?
|
private var task: URLSessionDataTask?
|
||||||
private var requests = [Request]()
|
private var requests = [Request]()
|
||||||
|
|
||||||
init(url: URL) {
|
init(url: URL, onFinished: @escaping (Data?) -> Void) {
|
||||||
self.url = url
|
self.url = url
|
||||||
|
self.onFinished = onFinished
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
task?.cancel()
|
task?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(cache: @escaping (Data) -> Void) {
|
func run() {
|
||||||
task = URLSession.shared.dataTask(with: url, completionHandler: { (data, response, error) in
|
task = URLSession.shared.dataTask(with: url, completionHandler: { (data, response, error) in
|
||||||
guard error == nil, let data = data else {
|
guard error == nil, let data = data else {
|
||||||
self.complete(with: nil)
|
self.complete(with: nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cache(data)
|
|
||||||
self.complete(with: data)
|
self.complete(with: data)
|
||||||
})
|
})
|
||||||
task!.resume()
|
task!.resume()
|
||||||
|
@ -123,11 +132,12 @@ class ImageCache {
|
||||||
callback(data)
|
callback(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.onFinished(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Request {
|
class Request {
|
||||||
weak var group: RequestGroup?
|
private weak var group: RequestGroup?
|
||||||
private(set) var callback: ((Data?) -> Void)?
|
private(set) var callback: ((Data?) -> Void)?
|
||||||
private(set) var cancelled: Bool = false
|
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
|
let instanceURL: URL
|
||||||
private(set) var accountInfo: LocalData.UserAccountInfo?
|
var accountInfo: LocalData.UserAccountInfo?
|
||||||
|
|
||||||
let client: Client!
|
let client: Client!
|
||||||
|
|
||||||
var account: Account!
|
var account: Account!
|
||||||
var instance: Instance!
|
var instance: Instance!
|
||||||
|
|
||||||
init(instanceURL: URL) {
|
var loggedIn: Bool {
|
||||||
|
accountInfo != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
init(instanceURL: URL, transient: Bool = false) {
|
||||||
self.instanceURL = instanceURL
|
self.instanceURL = instanceURL
|
||||||
self.accountInfo = nil
|
self.accountInfo = nil
|
||||||
self.client = Client(baseURL: instanceURL)
|
self.client = Client(baseURL: instanceURL)
|
||||||
|
self.transient = transient
|
||||||
}
|
}
|
||||||
|
|
||||||
func run<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) {
|
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 {
|
if account != nil {
|
||||||
completion?(account)
|
completion?(.success(account))
|
||||||
} else {
|
} else {
|
||||||
let request = Client.getSelfAccount()
|
let request = Client.getSelfAccount()
|
||||||
run(request) { response in
|
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.account = account
|
||||||
self.cache.add(account: account)
|
self.persistentContainer.backgroundContext.perform {
|
||||||
completion?(account)
|
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()
|
let request = Client.getInstance()
|
||||||
run(request) { (response) in
|
run(request) { (response) in
|
||||||
guard case let .success(instance, _) = response else { fatalError() }
|
guard case let .success(instance, _) = response else { fatalError() }
|
||||||
self.instance = instance
|
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 Foundation
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
extension Account {
|
extension AccountMO {
|
||||||
|
|
||||||
var realDisplayName: String {
|
var displayOrUserName: String {
|
||||||
if displayName.isEmpty {
|
if displayName.isEmpty {
|
||||||
return username
|
return username
|
||||||
} else if Preferences.shared.hideCustomEmojiInUsernames {
|
|
||||||
return stripCustomEmoji(from: displayName)
|
|
||||||
} else {
|
} else {
|
||||||
return displayName
|
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 static let customEmojiRegex = try! NSRegularExpression(pattern: ":[a-zA-Z0-9_]+:", options: [])
|
||||||
|
|
||||||
private func stripCustomEmoji(from string: String) -> String {
|
private func stripCustomEmoji(from string: String) -> String {
|
||||||
let range = NSRange(location: 0, length: string.utf16.count)
|
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 {
|
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) {
|
func timeAgo() -> (Int, Calendar.Component) {
|
||||||
let calendar = NSCalendar.current
|
let calendar = NSCalendar.current
|
||||||
let unitFlags = Set<Calendar.Component>([.second, .minute, .hour, .day, .weekOfYear, .month, .year])
|
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 {
|
extension UIViewController: UIViewControllerTransitioningDelegate {
|
||||||
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||||
if let presented = presented as? LargeImageViewController,
|
if let presented = presented as? LargeImageAnimatableViewController,
|
||||||
presented.sourceInfo?.image != nil {
|
presented.animationImage != nil {
|
||||||
return LargeImageExpandAnimationController()
|
return LargeImageExpandAnimationController()
|
||||||
} else if let presented = presented as? GalleryViewController,
|
|
||||||
presented.sourcesInfo[presented.startIndex]?.image != nil {
|
|
||||||
return GalleryExpandAnimationController()
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||||
if let dismissed = dismissed as? LargeImageViewController,
|
if let dismissed = dismissed as? LargeImageAnimatableViewController,
|
||||||
dismissed.imageForDismissalAnimation() != nil {
|
dismissed.animationImage != nil {
|
||||||
return LargeImageShrinkAnimationController(interactionController: dismissed.dismissInteractionController)
|
return LargeImageShrinkAnimationController(interactionController: dismissed.dismissInteractionController)
|
||||||
} else if let dismissed = dismissed as? GalleryViewController,
|
|
||||||
dismissed.imageForDismissalAnimation() != nil {
|
|
||||||
return GalleryShrinkAnimationController(interactionController: dismissed.dismissInteractionController)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -36,10 +30,6 @@ extension UIViewController: UIViewControllerTransitioningDelegate {
|
||||||
let interactionController = animator.interactionController,
|
let interactionController = animator.interactionController,
|
||||||
interactionController.inProgress {
|
interactionController.inProgress {
|
||||||
return interactionController
|
return interactionController
|
||||||
} else if let animator = animator as? GalleryShrinkAnimationController,
|
|
||||||
let interactionController = animator.interactionController,
|
|
||||||
interactionController.inProgress {
|
|
||||||
return interactionController
|
|
||||||
}
|
}
|
||||||
return nil
|
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>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
@ -30,7 +30,9 @@
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.social-networking</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
|
@ -98,5 +100,16 @@
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</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>
|
</dict>
|
||||||
</plist>
|
</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() {
|
private init() {
|
||||||
if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING") {
|
if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING") {
|
||||||
defaults = UserDefaults(suiteName: "\(Bundle.main.bundleIdentifier!).uitesting")!
|
defaults = UserDefaults(suiteName: "\(Bundle.main.bundleIdentifier!).uitesting")!
|
||||||
|
defaults.removePersistentDomain(forName: "\(Bundle.main.bundleIdentifier!).uitesting")
|
||||||
if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING_LOGIN") {
|
if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING_LOGIN") {
|
||||||
accounts = [
|
accounts = [
|
||||||
UserAccountInfo(
|
UserAccountInfo(
|
||||||
|
@ -30,7 +31,20 @@ class LocalData: ObservableObject {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
} else {
|
} 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 url = URL(string: instanceURL),
|
||||||
let clientId = info["clientID"],
|
let clientId = info["clientID"],
|
||||||
let secret = info["clientSecret"],
|
let secret = info["clientSecret"],
|
||||||
let username = info["username"],
|
|
||||||
let accessToken = info["accessToken"] else {
|
let accessToken = info["accessToken"] else {
|
||||||
return nil
|
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 {
|
} else {
|
||||||
return []
|
return []
|
||||||
|
@ -56,15 +69,18 @@ class LocalData: ObservableObject {
|
||||||
}
|
}
|
||||||
set {
|
set {
|
||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
let array = newValue.map { (info) in
|
let array = newValue.map { (info) -> [String: String] in
|
||||||
return [
|
var res = [
|
||||||
"id": info.id,
|
"id": info.id,
|
||||||
"instanceURL": info.instanceURL.absoluteString,
|
"instanceURL": info.instanceURL.absoluteString,
|
||||||
"clientID": info.clientID,
|
"clientID": info.clientID,
|
||||||
"clientSecret": info.clientSecret,
|
"clientSecret": info.clientSecret,
|
||||||
"username": info.username,
|
|
||||||
"accessToken": info.accessToken
|
"accessToken": info.accessToken
|
||||||
]
|
]
|
||||||
|
if let username = info.username {
|
||||||
|
res["username"] = username
|
||||||
|
}
|
||||||
|
return res
|
||||||
}
|
}
|
||||||
defaults.set(array, forKey: accountsKey)
|
defaults.set(array, forKey: accountsKey)
|
||||||
}
|
}
|
||||||
|
@ -85,7 +101,7 @@ class LocalData: ObservableObject {
|
||||||
return !accounts.isEmpty
|
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
|
var accounts = self.accounts
|
||||||
if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) {
|
if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) {
|
||||||
accounts.remove(at: index)
|
accounts.remove(at: index)
|
||||||
|
@ -97,6 +113,13 @@ class LocalData: ObservableObject {
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setUsername(for info: UserAccountInfo, username: String) {
|
||||||
|
var info = info
|
||||||
|
info.username = username
|
||||||
|
removeAccount(info)
|
||||||
|
accounts.append(info)
|
||||||
|
}
|
||||||
|
|
||||||
func removeAccount(_ info: UserAccountInfo) {
|
func removeAccount(_ info: UserAccountInfo) {
|
||||||
accounts.removeAll(where: { $0.id == info.id })
|
accounts.removeAll(where: { $0.id == info.id })
|
||||||
}
|
}
|
||||||
|
@ -128,7 +151,7 @@ extension LocalData {
|
||||||
let instanceURL: URL
|
let instanceURL: URL
|
||||||
let clientID: String
|
let clientID: String
|
||||||
let clientSecret: String
|
let clientSecret: String
|
||||||
let username: String
|
fileprivate(set) var username: String!
|
||||||
let accessToken: String
|
let accessToken: String
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
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
|
// Tusker
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 1/1/20.
|
// Created by Shadowfacts on 1/1/20.
|
||||||
|
@ -9,11 +9,13 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import Photos
|
import Photos
|
||||||
import MobileCoreServices
|
import MobileCoreServices
|
||||||
|
import PencilKit
|
||||||
|
|
||||||
enum CompositionAttachment {
|
enum CompositionAttachmentData {
|
||||||
case asset(PHAsset)
|
case asset(PHAsset)
|
||||||
case image(UIImage)
|
case image(UIImage)
|
||||||
case video(URL)
|
case video(URL)
|
||||||
|
case drawing(PKDrawing)
|
||||||
|
|
||||||
var type: AttachmentType {
|
var type: AttachmentType {
|
||||||
switch self {
|
switch self {
|
||||||
|
@ -23,6 +25,8 @@ enum CompositionAttachment {
|
||||||
return .image
|
return .image
|
||||||
case .video(_):
|
case .video(_):
|
||||||
return .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 {
|
switch self {
|
||||||
case let .image(image):
|
case let .image(image):
|
||||||
completion(image.pngData()!, "image/png")
|
completion(image.pngData()!, "image/png")
|
||||||
|
@ -79,7 +83,7 @@ enum CompositionAttachment {
|
||||||
options.version = .current
|
options.version = .current
|
||||||
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { (exportSession, info) in
|
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { (exportSession, info) in
|
||||||
guard let exportSession = exportSession else { fatalError("failed to create export session") }
|
guard let exportSession = exportSession else { fatalError("failed to create export session") }
|
||||||
CompositionAttachment.exportVideoData(session: exportSession, completion: completion)
|
CompositionAttachmentData.exportVideoData(session: exportSession, completion: completion)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fatalError("assetType must be either image or video")
|
fatalError("assetType must be either image or video")
|
||||||
|
@ -89,7 +93,11 @@ enum CompositionAttachment {
|
||||||
guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
|
guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
|
||||||
fatalError("failed to create export session")
|
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 {
|
extension PHAsset {
|
||||||
var attachmentType: CompositionAttachment.AttachmentType? {
|
var attachmentType: CompositionAttachmentData.AttachmentType? {
|
||||||
switch self.mediaType {
|
switch self.mediaType {
|
||||||
case .image:
|
case .image:
|
||||||
return .image
|
return .image
|
||||||
|
@ -125,7 +133,7 @@ extension PHAsset {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension CompositionAttachment: Codable {
|
extension CompositionAttachmentData: Codable {
|
||||||
func encode(to encoder: Encoder) throws {
|
func encode(to encoder: Encoder) throws {
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
@ -138,6 +146,10 @@ extension CompositionAttachment: Codable {
|
||||||
try container.encode(image.pngData()!, forKey: .imageData)
|
try container.encode(image.pngData()!, forKey: .imageData)
|
||||||
case .video(_):
|
case .video(_):
|
||||||
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: [], debugDescription: "video CompositionAttachments cannot be encoded"))
|
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")
|
throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "Could not decode UIImage from image data")
|
||||||
}
|
}
|
||||||
self = .image(image)
|
self = .image(image)
|
||||||
|
case "drawing":
|
||||||
|
let drawingData = try container.decode(Data.self, forKey: .drawing)
|
||||||
|
let drawing = try PKDrawing(data: drawingData)
|
||||||
|
self = .drawing(drawing)
|
||||||
default:
|
default:
|
||||||
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of 'image' or 'asset'")
|
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
|
case imageData
|
||||||
/// The local identifier of the PHAsset for this attachment
|
/// The local identifier of the PHAsset for this attachment
|
||||||
case assetIdentifier
|
case assetIdentifier
|
||||||
|
/// The PKDrawing object for this attachment.
|
||||||
|
case drawing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension CompositionAttachment: Equatable {
|
extension CompositionAttachmentData: Equatable {
|
||||||
static func ==(lhs: CompositionAttachment, rhs: CompositionAttachment) -> Bool {
|
static func ==(lhs: CompositionAttachmentData, rhs: CompositionAttachmentData) -> Bool {
|
||||||
switch (lhs, rhs) {
|
switch (lhs, rhs) {
|
||||||
case let (.asset(a), .asset(b)):
|
case let (.asset(a), .asset(b)):
|
||||||
return a.localIdentifier == b.localIdentifier
|
return a.localIdentifier == b.localIdentifier
|
||||||
case let (.image(a), .image(b)):
|
case let (.image(a), .image(b)):
|
||||||
return a == b
|
return a == b
|
||||||
|
case let (.video(a), .video(b)):
|
||||||
|
return a == b
|
||||||
|
case let (.drawing(a), .drawing(b)):
|
||||||
|
return a == b
|
||||||
default:
|
default:
|
||||||
return false
|
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)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme)
|
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.avatarStyle = try container.decode(AvatarStyle.self, forKey: .avatarStyle)
|
||||||
self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames)
|
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.defaultPostVisibility = try container.decode(Status.Visibility.self, forKey: .defaultPostVisibility)
|
||||||
self.automaticallySaveDrafts = try container.decode(Bool.self, forKey: .automaticallySaveDrafts)
|
self.automaticallySaveDrafts = try container.decode(Bool.self, forKey: .automaticallySaveDrafts)
|
||||||
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
|
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
|
||||||
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
|
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
|
||||||
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
|
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
|
||||||
|
|
||||||
self.blurAllMedia = try container.decode(Bool.self, forKey: .blurAllMedia)
|
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.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
|
||||||
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
|
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
|
||||||
self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode)
|
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.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
|
||||||
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
|
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
|
||||||
|
@ -63,19 +73,25 @@ class Preferences: Codable, ObservableObject {
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
try container.encode(theme, forKey: .theme)
|
try container.encode(theme, forKey: .theme)
|
||||||
try container.encode(showRepliesInProfiles, forKey: .showRepliesInProfiles)
|
|
||||||
try container.encode(avatarStyle, forKey: .avatarStyle)
|
try container.encode(avatarStyle, forKey: .avatarStyle)
|
||||||
try container.encode(hideCustomEmojiInUsernames, forKey: .hideCustomEmojiInUsernames)
|
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(defaultPostVisibility, forKey: .defaultPostVisibility)
|
||||||
try container.encode(automaticallySaveDrafts, forKey: .automaticallySaveDrafts)
|
try container.encode(automaticallySaveDrafts, forKey: .automaticallySaveDrafts)
|
||||||
try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions)
|
try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions)
|
||||||
try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode)
|
try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode)
|
||||||
try container.encode(mentionReblogger, forKey: .mentionReblogger)
|
try container.encode(mentionReblogger, forKey: .mentionReblogger)
|
||||||
|
|
||||||
try container.encode(blurAllMedia, forKey: .blurAllMedia)
|
try container.encode(blurAllMedia, forKey: .blurAllMedia)
|
||||||
|
try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
|
||||||
|
|
||||||
try container.encode(openLinksInApps, forKey: .openLinksInApps)
|
try container.encode(openLinksInApps, forKey: .openLinksInApps)
|
||||||
try container.encode(useInAppSafari, forKey: .useInAppSafari)
|
try container.encode(useInAppSafari, forKey: .useInAppSafari)
|
||||||
try container.encode(inAppSafariAutomaticReaderMode, forKey: .inAppSafariAutomaticReaderMode)
|
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(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts)
|
||||||
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
|
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
|
||||||
|
@ -84,46 +100,60 @@ class Preferences: Codable, ObservableObject {
|
||||||
try container.encode(statusContentType, forKey: .statusContentType)
|
try container.encode(statusContentType, forKey: .statusContentType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Appearance
|
// MARK: Appearance
|
||||||
@Published var theme = UIUserInterfaceStyle.unspecified
|
@Published var theme = UIUserInterfaceStyle.unspecified
|
||||||
@Published var showRepliesInProfiles = false
|
|
||||||
@Published var avatarStyle = AvatarStyle.roundRect
|
@Published var avatarStyle = AvatarStyle.roundRect
|
||||||
@Published var hideCustomEmojiInUsernames = false
|
@Published var hideCustomEmojiInUsernames = false
|
||||||
|
@Published var showIsStatusReplyIcon = false
|
||||||
|
@Published var alwaysShowStatusVisibilityIcon = false
|
||||||
|
|
||||||
// MARK: - Behavior
|
// MARK: Composing
|
||||||
@Published var defaultPostVisibility = Status.Visibility.public
|
@Published var defaultPostVisibility = Status.Visibility.public
|
||||||
@Published var automaticallySaveDrafts = true
|
@Published var automaticallySaveDrafts = true
|
||||||
@Published var requireAttachmentDescriptions = false
|
@Published var requireAttachmentDescriptions = false
|
||||||
@Published var contentWarningCopyMode = ContentWarningCopyMode.asIs
|
@Published var contentWarningCopyMode = ContentWarningCopyMode.asIs
|
||||||
@Published var mentionReblogger = false
|
@Published var mentionReblogger = false
|
||||||
|
|
||||||
|
// MARK: Media
|
||||||
@Published var blurAllMedia = false
|
@Published var blurAllMedia = false
|
||||||
|
@Published var automaticallyPlayGifs = true
|
||||||
|
|
||||||
|
// MARK: Behavior
|
||||||
@Published var openLinksInApps = true
|
@Published var openLinksInApps = true
|
||||||
@Published var useInAppSafari = true
|
@Published var useInAppSafari = true
|
||||||
@Published var inAppSafariAutomaticReaderMode = false
|
@Published var inAppSafariAutomaticReaderMode = false
|
||||||
|
@Published var expandAllContentWarnings = false
|
||||||
|
@Published var collapseLongPosts = true
|
||||||
|
|
||||||
// MARK: - Digital Wellness
|
// MARK: Digital Wellness
|
||||||
@Published var showFavoriteAndReblogCounts = true
|
@Published var showFavoriteAndReblogCounts = true
|
||||||
@Published var defaultNotificationsMode = NotificationsMode.allNotifications
|
@Published var defaultNotificationsMode = NotificationsMode.allNotifications
|
||||||
|
|
||||||
// MARK: - Advanced
|
// MARK: Advanced
|
||||||
@Published var silentActions: [String: Permission] = [:]
|
@Published var silentActions: [String: Permission] = [:]
|
||||||
@Published var statusContentType: StatusContentType = .plain
|
@Published var statusContentType: StatusContentType = .plain
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case theme
|
case theme
|
||||||
case showRepliesInProfiles
|
|
||||||
case avatarStyle
|
case avatarStyle
|
||||||
case hideCustomEmojiInUsernames
|
case hideCustomEmojiInUsernames
|
||||||
|
case showIsStatusReplyIcon
|
||||||
|
case alwaysShowStatusVisibilityIcon
|
||||||
|
|
||||||
case defaultPostVisibility
|
case defaultPostVisibility
|
||||||
case automaticallySaveDrafts
|
case automaticallySaveDrafts
|
||||||
case requireAttachmentDescriptions
|
case requireAttachmentDescriptions
|
||||||
case contentWarningCopyMode
|
case contentWarningCopyMode
|
||||||
case mentionReblogger
|
case mentionReblogger
|
||||||
|
|
||||||
case blurAllMedia
|
case blurAllMedia
|
||||||
|
case automaticallyPlayGifs
|
||||||
|
|
||||||
case openLinksInApps
|
case openLinksInApps
|
||||||
case useInAppSafari
|
case useInAppSafari
|
||||||
case inAppSafariAutomaticReaderMode
|
case inAppSafariAutomaticReaderMode
|
||||||
|
case expandAllContentWarnings
|
||||||
|
case collapseLongPosts
|
||||||
|
|
||||||
case showFavoriteAndReblogCounts
|
case showFavoriteAndReblogCounts
|
||||||
case defaultNotificationsType
|
case defaultNotificationsType
|
||||||
|
|
|
@ -8,6 +8,8 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
import CrashReporter
|
||||||
|
import MessageUI
|
||||||
|
|
||||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
|
|
||||||
|
@ -21,15 +23,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
|
|
||||||
window = UIWindow(windowScene: windowScene)
|
window = UIWindow(windowScene: windowScene)
|
||||||
|
|
||||||
if LocalData.shared.onboardingComplete {
|
if let report = AppDelegate.pendingCrashReport {
|
||||||
if session.mastodonController == nil {
|
AppDelegate.pendingCrashReport = nil
|
||||||
let account = LocalData.shared.getMostRecentAccount()!
|
handlePendingCrashReport(report, session: session)
|
||||||
session.mastodonController = MastodonController.getForAccount(account)
|
|
||||||
}
|
|
||||||
|
|
||||||
showAppUI()
|
|
||||||
} else {
|
} else {
|
||||||
showOnboardingUI()
|
showAppOrOnboardingUI(session: session)
|
||||||
}
|
}
|
||||||
|
|
||||||
window!.makeKeyAndVisible()
|
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.
|
// 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.
|
// 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).
|
// 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) {
|
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||||
|
@ -92,6 +93,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
func sceneWillResignActive(_ scene: UIScene) {
|
func sceneWillResignActive(_ scene: UIScene) {
|
||||||
// Called when the scene will move from an active state to an inactive state.
|
// 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).
|
// This may occur due to temporary interruptions (ex. an incoming phone call).
|
||||||
|
|
||||||
|
Preferences.save()
|
||||||
|
DraftsManager.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func sceneWillEnterForeground(_ scene: UIScene) {
|
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
|
// 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.
|
// to restore the scene back to its current state.
|
||||||
|
|
||||||
Preferences.save()
|
try! scene.session.mastodonController?.persistentContainer.viewContext.save()
|
||||||
DraftsManager.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) {
|
func activateAccount(_ account: LocalData.UserAccountInfo) {
|
||||||
|
@ -128,8 +157,17 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
mastodonController.getOwnAccount()
|
mastodonController.getOwnAccount()
|
||||||
mastodonController.getOwnInstance()
|
mastodonController.getOwnInstance()
|
||||||
|
|
||||||
let tabBarController = MainTabBarViewController(mastodonController: mastodonController)
|
let rootController: UIViewController
|
||||||
window!.rootViewController = tabBarController
|
#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() {
|
func showOnboardingUI() {
|
||||||
|
@ -149,3 +187,11 @@ extension SceneDelegate: OnboardingViewControllerDelegate {
|
||||||
activateAccount(account)
|
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.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell)
|
||||||
|
|
||||||
tableView.rowHeight = 66
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
|
tableView.estimatedRowHeight = 66
|
||||||
|
|
||||||
tableView.alwaysBounceVertical = true
|
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() {
|
override func viewDidLoad() {
|
||||||
assetPicker.view.layer.cornerRadius = view.bounds.width * 0.02
|
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()
|
super.viewDidLoad()
|
||||||
}
|
}
|
||||||
|
@ -40,13 +42,6 @@ class AssetPickerSheetContainerViewController: SheetContainerViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AssetPickerSheetContainerViewController: SheetContainerViewControllerDelegate {
|
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? {
|
func sheetContainerContentScrollView(_ sheetContainer: SheetContainerViewController) -> UIScrollView? {
|
||||||
if let vc = assetPicker.visibleViewController as? UITableViewController {
|
if let vc = assetPicker.visibleViewController as? UITableViewController {
|
||||||
return vc.tableView
|
return vc.tableView
|
|
@ -9,16 +9,16 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import Photos
|
import Photos
|
||||||
|
|
||||||
protocol AssetPickerViewControllerDelegate {
|
protocol AssetPickerViewControllerDelegate: class {
|
||||||
func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachment.AttachmentType) -> Bool
|
func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool
|
||||||
func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachment])
|
func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachmentData])
|
||||||
}
|
}
|
||||||
|
|
||||||
class AssetPickerViewController: UINavigationController {
|
class AssetPickerViewController: UINavigationController {
|
||||||
|
|
||||||
var assetPickerDelegate: AssetPickerViewControllerDelegate?
|
weak var assetPickerDelegate: AssetPickerViewControllerDelegate?
|
||||||
|
|
||||||
var currentCollectionSelectedAssets: [CompositionAttachment] {
|
var currentCollectionSelectedAssets: [CompositionAttachmentData] {
|
||||||
if let vc = visibleViewController as? AssetCollectionViewController {
|
if let vc = visibleViewController as? AssetCollectionViewController {
|
||||||
return vc.selectedAssets.map { .asset($0) }
|
return vc.selectedAssets.map { .asset($0) }
|
||||||
} else {
|
} else {
|
||||||
|
@ -70,7 +70,7 @@ extension AssetPickerViewController: AssetCollectionViewControllerDelegate {
|
||||||
|
|
||||||
extension AssetPickerViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
extension AssetPickerViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
|
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
|
||||||
let attachment: CompositionAttachment
|
let attachment: CompositionAttachmentData
|
||||||
if let image = info[.originalImage] as? UIImage {
|
if let image = info[.originalImage] as? UIImage {
|
||||||
attachment = .image(image)
|
attachment = .image(image)
|
||||||
} else if let url = info[.mediaURL] as? URL {
|
} else if let url = info[.mediaURL] as? URL {
|
|
@ -13,14 +13,18 @@ import AVKit
|
||||||
|
|
||||||
class AssetPreviewViewController: UIViewController {
|
class AssetPreviewViewController: UIViewController {
|
||||||
|
|
||||||
let asset: PHAsset
|
let attachment: CompositionAttachmentData
|
||||||
|
|
||||||
init(asset: PHAsset) {
|
init(attachment: CompositionAttachmentData) {
|
||||||
self.asset = asset
|
self.attachment = attachment
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
convenience init(asset: PHAsset) {
|
||||||
|
self.init(attachment: .asset(asset))
|
||||||
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
@ -30,21 +34,32 @@ class AssetPreviewViewController: UIViewController {
|
||||||
|
|
||||||
view.backgroundColor = .black
|
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) {
|
if asset.mediaSubtypes.contains(.photoLive) {
|
||||||
showLivePhoto()
|
showLivePhoto(asset)
|
||||||
} else {
|
} else {
|
||||||
showImage()
|
showAssetImage(asset)
|
||||||
}
|
}
|
||||||
} else if asset.mediaType == .video {
|
case .video:
|
||||||
playVideo()
|
showAssetVideo(asset)
|
||||||
} else {
|
default:
|
||||||
fatalError("asset mediaType must be image or video")
|
fatalError("asset mediaType must be image or video")
|
||||||
}
|
}
|
||||||
|
case let .drawing(drawing):
|
||||||
|
let image = drawing.imageInLightMode(from: drawing.bounds)
|
||||||
|
showImage(image)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func showImage() {
|
func showImage(_ image: UIImage) {
|
||||||
let imageView = UIImageView()
|
let imageView = UIImageView(image: image)
|
||||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
imageView.contentMode = .scaleAspectFit
|
imageView.contentMode = .scaleAspectFit
|
||||||
view.addSubview(imageView)
|
view.addSubview(imageView)
|
||||||
|
@ -54,7 +69,10 @@ class AssetPreviewViewController: UIViewController {
|
||||||
imageView.topAnchor.constraint(equalTo: view.topAnchor),
|
imageView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
imageView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
imageView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||||
])
|
])
|
||||||
|
preferredContentSize = image.size
|
||||||
|
}
|
||||||
|
|
||||||
|
func showAssetImage(_ asset: PHAsset) {
|
||||||
let options = PHImageRequestOptions()
|
let options = PHImageRequestOptions()
|
||||||
options.version = .current
|
options.version = .current
|
||||||
options.deliveryMode = .opportunistic
|
options.deliveryMode = .opportunistic
|
||||||
|
@ -62,12 +80,12 @@ class AssetPreviewViewController: UIViewController {
|
||||||
options.isNetworkAccessAllowed = true
|
options.isNetworkAccessAllowed = true
|
||||||
PHImageManager.default().requestImage(for: asset, targetSize: view.bounds.size, contentMode: .aspectFit, options: options) { (image, _) in
|
PHImageManager.default().requestImage(for: asset, targetSize: view.bounds.size, contentMode: .aspectFit, options: options) { (image, _) in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
imageView.image = image
|
self.showImage(image!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func showLivePhoto() {
|
func showLivePhoto(_ asset: PHAsset) {
|
||||||
let options = PHLivePhotoRequestOptions()
|
let options = PHLivePhotoRequestOptions()
|
||||||
options.deliveryMode = .opportunistic
|
options.deliveryMode = .opportunistic
|
||||||
options.version = .current
|
options.version = .current
|
||||||
|
@ -90,11 +108,23 @@ class AssetPreviewViewController: UIViewController {
|
||||||
livePhotoView.topAnchor.constraint(equalTo: self.view.topAnchor),
|
livePhotoView.topAnchor.constraint(equalTo: self.view.topAnchor),
|
||||||
livePhotoView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor)
|
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()
|
let options = PHVideoRequestOptions()
|
||||||
options.deliveryMode = .automatic
|
options.deliveryMode = .automatic
|
||||||
options.isNetworkAccessAllowed = true
|
options.isNetworkAccessAllowed = true
|
||||||
|
@ -104,13 +134,7 @@ class AssetPreviewViewController: UIViewController {
|
||||||
fatalError("failed to get AVAsset")
|
fatalError("failed to get AVAsset")
|
||||||
}
|
}
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
let playerController = AVPlayerViewController()
|
self.showVideo(asset: avAsset)
|
||||||
let item = AVPlayerItem(asset: avAsset)
|
|
||||||
let player = AVPlayer(playerItem: item)
|
|
||||||
player.isMuted = true
|
|
||||||
player.play()
|
|
||||||
playerController.player = player
|
|
||||||
self.embedChild(playerController)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|