Compare commits

...

110 Commits

Author SHA1 Message Date
Shadowfacts 4719342a06
Bump build number and update changelog 2020-09-15 22:22:20 -04:00
Shadowfacts 6df5f7fb08
Add preferences for auto-expanding CW'd posts and disabling long post
collapsing

See #105
2020-09-15 21:37:08 -04:00
Shadowfacts 02135aa0de
Use inset list style for preferences on iOS 14 2020-09-15 20:48:53 -04:00
Shadowfacts be5a4c03a6
Fix attachments not being posted in the correct order. 2020-09-14 23:29:31 -04:00
Shadowfacts 2c1ba7926e
Support JSON request bodies 2020-09-14 23:25:26 -04:00
Shadowfacts 911e66a159
Allow more browsing of instance public timelines
Closes #74
2020-09-13 15:51:08 -04:00
Shadowfacts ab4bcfa50f
Fix profile screen title not being set 2020-09-13 15:34:45 -04:00
Shadowfacts b94bfca406
Fix crash tapping attachments on instance public timelines 2020-09-13 13:55:33 -04:00
Shadowfacts 7999ecafd0
Update SheetController 2020-09-13 13:27:52 -04:00
Shadowfacts 1c6e464a4c
Start Compose screen tests 2020-09-13 13:19:56 -04:00
Shadowfacts acd01a81cc
More UI tests for onboarding/my profile 2020-09-12 22:16:58 -04:00
Shadowfacts 8ac3deb55a
Remove old file 2020-09-12 22:04:41 -04:00
Shadowfacts 5e9cc430c6
Use cross fade transitions for displaying gallery and asset picker if
Reduce Motion/Prefer Cross Fade is enabled

Closes #108
2020-09-12 13:25:59 -04:00
Shadowfacts 0b6ef6517b
Fix gallery action buttons not being centered in device "ears" on iPhone
XR and 11
2020-09-12 12:01:16 -04:00
Shadowfacts 34a01094f7
Fix gallery expand animation description not starting at correct
position

Safe are insets weren't being taken into account when hiding the
controls, because the toVC had not yet been added to the container view
and thus didn't have anything to receive insets from.
2020-09-12 12:01:16 -04:00
Shadowfacts 95b215c6b5
Add Clear Image Cache option to Advanced prefs 2020-09-12 12:01:16 -04:00
Shadowfacts e21dceb3b3
Tweak gallery spring animation parameters 2020-09-12 12:01:16 -04:00
Shadowfacts 9534f19262
Show BlurHash previews of attachments 2020-09-12 12:01:08 -04:00
Shadowfacts e44ae29775
Improve asset picker opening animation 2020-09-10 23:24:24 -04:00
Shadowfacts a5b30c4243
Update PLCrashReporter 2020-09-10 23:24:14 -04:00
Shadowfacts 479ca23e00
Tweak follow request notification cells 2020-09-10 22:54:01 -04:00
Shadowfacts 5b03e0cf12
Fix follow notifications not showing names for users without explicit
display names
2020-09-09 18:45:38 -04:00
Shadowfacts 7c4bbfd730
Improve compose posting error messages 2020-09-09 18:33:59 -04:00
Shadowfacts e19a6528ad
Improve gallery expand animation
Use spring timing, slide in top/bottom controls
2020-09-08 23:41:15 -04:00
Shadowfacts f5110c773a
Tweak default font sizes 2020-09-07 18:49:25 -04:00
Shadowfacts fe1db72f19
Fix save draft sheet showing even when draft had no content 2020-09-07 17:15:18 -04:00
Shadowfacts b4ddb8f533
Fix safe area on Compose screen not including keyboard on iOS 13 2020-09-07 17:05:50 -04:00
Shadowfacts 9a4ddfea3f
Fix Compose reply scroll effect not working on iOS 13 2020-09-07 16:56:06 -04:00
Shadowfacts dd8a196630
Show custom emoji in display names on Compose screen 2020-09-07 15:22:06 -04:00
Shadowfacts 3da7aacb35
Fix visiblity context menu in main text view accessory not updating 2020-09-07 14:46:17 -04:00
Shadowfacts 39c8162931
Prevent attempting to add an attachment when the possibility would be
invalid
2020-09-07 14:44:56 -04:00
Shadowfacts fe95cb9e1a
Replace Draw Something context menu item with dedicated button
Fixes add attachment button not working on iOS 13. Adding a context menu
to a Button inside a List on iOS 13 prevents the button from ever
recognizing taps.
2020-09-07 14:41:31 -04:00
Shadowfacts ec2d510be2
Fix crash when opening Compose screen on iOS 13 2020-09-06 23:27:43 -04:00
Shadowfacts 262aadf807
Fix very bad performance when laying out Compose reply view
Using a non-scrolling UITextView wrapped in SwiftUI combined with the
old hack of fixing its layout by passing the view controller's width
down to the wrapped view caused very slow layouts, resulting in
significant lag when typing into the main text view of the compose
screen.
2020-09-06 22:47:02 -04:00
Shadowfacts 9dce94c014
Fix acounts not updating locally
Fix reblogged statuses potentially not updating
2020-09-06 16:03:03 -04:00
Shadowfacts d008b882cb
Use context menu for visibility on iOS 14 2020-08-31 23:07:41 -04:00
Shadowfacts 3d13df87f0
Add pointer interaction to main status favorites/reblogs buttons 2020-08-31 21:40:18 -04:00
Shadowfacts f0582739cc
Re-add Compose button to Profile screen
Add menu with Direct Message option
2020-08-31 21:39:36 -04:00
Shadowfacts 4c82b1a341
Rewrite Compose screen in SwiftUI 2020-08-31 19:28:50 -04:00
Shadowfacts b55a96d649
Fix crash when resizing split view on iPad if Explore search controller
hadn't been created yet
2020-08-30 19:57:32 -04:00
Shadowfacts 77ac8cbe40
Bump deployment target to iOS 13.4 2020-08-30 19:28:11 -04:00
Shadowfacts e026c9a6c6
Bump build number and update changelog 2020-08-17 19:06:56 -04:00
Shadowfacts 3937dde2bf
Fix crash when selecting attachments on iOS 13 2020-08-17 18:52:54 -04:00
Shadowfacts 95ebca04d2
Disable automatic GIF playback in low-power mode 2020-08-16 19:14:32 -04:00
Shadowfacts 0986fa285e
Fix crash due to leaked table view cell 2020-08-16 15:07:59 -04:00
Shadowfacts 1cd3e6adf9
Show custom emoji in profile field names 2020-08-16 15:07:55 -04:00
Shadowfacts 722b81dad9
Group appearance prefs into sections 2020-08-16 14:58:10 -04:00
Shadowfacts 059f7307b3
Let system uppercase section headers 2020-08-16 14:58:02 -04:00
Shadowfacts ee20c95a5d
Prevent link activation when outside character 2020-08-16 14:52:08 -04:00
Shadowfacts be81ffb61f
Allow display names to shrink to fit available width 2020-08-16 14:49:44 -04:00
Shadowfacts 08e0c3769f
Make link preview background opaque 2020-08-16 14:45:01 -04:00
Shadowfacts 6d7c9fd553
Make tap targets on status action buttons larger 2020-08-16 14:41:30 -04:00
Shadowfacts 9b04b75949
Prevent potential race condition when loading additional statuses 2020-08-16 10:29:31 -04:00
Shadowfacts 273b74ddfb
Bump build number and update changelog 2020-08-15 22:10:44 -04:00
Shadowfacts ae055f1ffd
Remove debug code 2020-08-15 18:00:47 -04:00
Shadowfacts eef9b96a1a
Fix crash when showing profile for uncached account 2020-08-15 18:00:18 -04:00
Shadowfacts 29aed65b99
Fix crash if profile header view outlives VC 2020-08-15 17:59:14 -04:00
Shadowfacts 090746f292
Disallow opening universal links from Open in Safari context menu action 2020-08-15 17:48:58 -04:00
Shadowfacts af300a3559
Remove unused TuskerNavigationDelegate customization points 2020-08-15 17:47:33 -04:00
Shadowfacts 79eb23ef5d
Remove unused preference 2020-08-15 17:43:31 -04:00
Shadowfacts 60565f9625
Fix crash if status table view cell outlives VC 2020-08-15 17:37:56 -04:00
Shadowfacts 70bedf17a8
Set app category 2020-08-15 17:36:23 -04:00
Shadowfacts 392e51eb3e
Remove unnecessary prefernces change notification 2020-08-15 17:31:24 -04:00
Shadowfacts 86d5a73c85
Change menu item order
Open in Safari should be the closest to the user's finger when tapping a
menu button
2020-08-15 17:20:09 -04:00
Shadowfacts eaefa366b7
Fix displaying images on iOS 14 2020-08-15 17:03:02 -04:00
Shadowfacts 79b23127e9
Fix crash on refreshing 2020-08-15 14:15:38 -04:00
Shadowfacts f9b85c87b4
Fix crash on launch due to unloaded sidebar VC 2020-08-15 13:55:47 -04:00
Shadowfacts 260bedcf10
Fix retain cycle between status cells and menu actions 2020-07-07 23:23:39 -04:00
Shadowfacts fe09c5e522
Switch asset picker to use diffable data sources 2020-07-06 18:16:18 -04:00
Shadowfacts 985d30a401 Add background to image descriptions so they're visible against light backgrounds
Closes #102
2020-07-06 17:48:19 -04:00
Shadowfacts 794594805c Prevent needlessly prefetching non-image attachments 2020-07-06 00:00:55 -04:00
Shadowfacts 1c708732f2 Exclude iOS 14-specific code from compilation on Xcode 11 to allow building for TestFlight 2020-07-06 00:00:51 -04:00
Shadowfacts db30471011 Fix not being able to refresh timelines 2020-07-05 16:30:16 -04:00
Shadowfacts 2825345c7e Add switching between Posts, Posts and Replies, and Media pages of user profiles
Closes #103
2020-07-05 16:17:56 -04:00
Shadowfacts f3d01c47c3 Merge branch 'develop-xcode-12' into ios-14 2020-07-04 11:21:00 -04:00
Shadowfacts caab5e357a Fix crash loading audio attachment uploaded on Mastodon
Closes #104
2020-07-03 22:13:49 -04:00
Shadowfacts 2916d7a72d Add tapping the active tab bar item to scroll to top
Closes #106
2020-07-03 19:36:52 -04:00
Shadowfacts d190636fbd Fix Preferences button not appearing (again) 2020-07-03 19:36:08 -04:00
Shadowfacts 4e4701ead5 Use SwiftSoup from SPM instead of Git submodule 2020-07-03 19:09:58 -04:00
Shadowfacts b07efc150c Use App Group for user defaults 2020-07-03 18:54:21 -04:00
Shadowfacts 19fa12391d Fix Preferences button not appearing 2020-07-03 18:53:19 -04:00
Shadowfacts c55ea2e005 More link context menu preview tweaks 2020-07-03 18:52:35 -04:00
Shadowfacts 47dc00ab8f Fix sometimes broken masking of text view link preview animations 2020-07-03 18:52:23 -04:00
Shadowfacts fdcdbced38 Limit context menu previews in ContentTextView to link's text line rects 2020-07-03 18:50:37 -04:00
Shadowfacts e70a84274e Fix showing instance public timeline 2020-07-03 18:50:22 -04:00
Shadowfacts 641ab765a7 Fix crash when displaying search results 2020-07-03 18:50:05 -04:00
Shadowfacts 986fc5b833 Prevent crash when displaying accounts with no pinned statuses 2020-07-03 18:49:55 -04:00
Shadowfacts cf5b97d9c8 Fix crash showing custom instance on iOS 14 2020-07-03 18:49:28 -04:00
Shadowfacts 7f0fd119c5 Use App Group for user defaults 2020-07-03 18:45:37 -04:00
Shadowfacts b2c7735256 Fix Preferences button not appearing 2020-07-03 18:44:38 -04:00
Shadowfacts 1d815d6cd6 More link context menu preview tweaks 2020-07-03 17:01:52 -04:00
Shadowfacts f86d3a0ed1 Fix sometimes broken masking of text view link preview animations 2020-07-01 00:01:36 -04:00
Shadowfacts 864fd77ecc Sync active tab and navigation stack between split view/tab bar controllers 2020-06-29 22:21:03 -04:00
Shadowfacts 78da04162f Fix missing file from project.pbxproj 2020-06-29 21:47:11 -04:00
Shadowfacts 40a742139b Fix menu state getting out of sync with bookmarked/muted state 2020-06-27 13:13:04 -04:00
Shadowfacts 8bbc572fa7 Replace more with share button for timeline status swipe actions 2020-06-27 10:47:31 -04:00
Shadowfacts 2a8e970738 Use context menus as primary actions for 'More Actions' buttons on >= iOS 14 2020-06-27 00:22:14 -04:00
Shadowfacts 3abb5972b9 Limit context menu previews in ContentTextView to link's text line rects 2020-06-25 10:42:46 -04:00
Shadowfacts 0c06d91f6b Fix showing instance public timeline 2020-06-24 16:41:01 -04:00
Shadowfacts 6cf6db6a8d Add sidebar on iPadOS 14 2020-06-24 16:40:45 -04:00
Shadowfacts fb11e36467 Fix crash when displaying search results 2020-06-24 15:42:56 -04:00
Shadowfacts 0fa87e9177 Prevent crash when displaying accounts with no pinned statuses 2020-06-23 22:21:50 -04:00
Shadowfacts 5cb84e271a Prefer ephemeral sessions in ASWebAuthneticationSession 2020-06-23 21:35:14 -04:00
Shadowfacts 50f1a9a7de Change ComposeDrawingViewController to use drawingPolicy on iOS 14 2020-06-23 19:33:14 -04:00
Shadowfacts 154fc7cd02 Fix ASWebAuthenticationSession usage in Catalyst 2020-06-23 19:32:30 -04:00
Shadowfacts 01d765fa45 Enable Catalyst 2020-06-23 19:32:04 -04:00
Shadowfacts 04aad1252a Use SwiftSoup from SPM instead of Git submodule 2020-06-23 19:31:32 -04:00
Shadowfacts 43779e42df Fix crash showing custom instance on iOS 14 2020-06-23 19:27:34 -04:00
Shadowfacts a5a2cd147e
Fix attachment blur view missing corner radius 2020-06-22 21:03:08 -04:00
Shadowfacts 0e91fc239d
Fix missing anchor for Compose screen visibility popover 2020-06-22 09:53:20 -04:00
132 changed files with 6033 additions and 3379 deletions

3
.gitmodules vendored
View File

@ -1,6 +1,3 @@
[submodule "SwiftSoup"]
path = SwiftSoup
url = git://github.com/scinfu/SwiftSoup.git
[submodule "Cache"]
path = Cache
url = git@github.com:hyperoslo/Cache.git

View File

@ -1,5 +1,77 @@
# Changelog
## 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.

2
Gifu

@ -1 +1 @@
Subproject commit ed572f53ce58b8e23499abeb3a926033cbe480f7
Subproject commit 9b1a6461aa3b5f66cb0ed3a50c5523db0b4fb007

View File

@ -26,7 +26,7 @@ public class Client {
public var timeoutInterval: TimeInterval = 60
lazy var decoder: JSONDecoder = {
static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
@ -36,6 +36,16 @@ public class Client {
return decoder
}()
static let encoder: JSONEncoder = {
let encoder = JSONEncoder()
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
formatter.timeZone = TimeZone(abbreviation: "UTC")
formatter.locale = Locale(identifier: "en_US_POSIX")
encoder.dateEncodingStrategy = .formatted(formatter)
return encoder
}()
public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
self.baseURL = baseURL
self.accessToken = accessToken
@ -50,22 +60,22 @@ public class Client {
let task = session.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
completion(.failure(.networkError(error)))
return
}
guard let data = data,
let response = response as? HTTPURLResponse else {
completion(.failure(Error.invalidResponse))
completion(.failure(.invalidResponse))
return
}
guard response.statusCode == 200 else {
let mastodonError = try? self.decoder.decode(MastodonError.self, from: data)
let error = mastodonError.flatMap { Error.mastodonError($0.description) } ?? Error.unknownError
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
let error: Error = mastodonError.flatMap { .mastodonError($0.description) } ?? .unexpectedStatus(response.statusCode)
completion(.failure(error))
return
}
guard let result = try? self.decoder.decode(Result.self, from: data) else {
completion(.failure(Error.invalidModel))
guard let result = try? Client.decoder.decode(Result.self, from: data) else {
completion(.failure(.invalidModel))
return
}
let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
@ -92,7 +102,7 @@ public class Client {
// MARK: - Authorization
public func registerApp(name: String, redirectURI: String, scopes: [Scope], website: URL? = nil, completion: @escaping Callback<RegisteredApplication>) {
let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: .parameters([
let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: ParametersBody([
"client_name" => name,
"redirect_uris" => redirectURI,
"scopes" => scopes.scopeString,
@ -109,7 +119,7 @@ public class Client {
}
public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) {
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: .parameters([
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: ParametersBody([
"client_id" => clientID,
"client_secret" => clientSecret,
"grant_type" => "authorization_code",
@ -168,13 +178,13 @@ public class Client {
}
public static func block(domain: String) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: .parameters([
return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: ParametersBody([
"domain" => domain
]))
}
public static func unblock(domain: String) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: .parameters([
return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: ParametersBody([
"domain" => domain
]))
}
@ -185,7 +195,7 @@ public class Client {
}
public static func createFilter(phrase: String, context: [Filter.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
return Request<Filter>(method: .post, path: "/api/v1/filters", body: .parameters([
return Request<Filter>(method: .post, path: "/api/v1/filters", body: ParametersBody([
"phrase" => phrase,
"irreversible" => irreversible,
"whole_word" => wholeWord,
@ -209,7 +219,7 @@ public class Client {
}
public static func followRemote(acct: String) -> Request<Account> {
return Request<Account>(method: .post, path: "/api/v1/follows", body: .parameters(["uri" => acct]))
return Request<Account>(method: .post, path: "/api/v1/follows", body: ParametersBody(["uri" => acct]))
}
// MARK: - Lists
@ -222,12 +232,12 @@ public class Client {
}
public static func createList(title: String) -> Request<List> {
return Request<List>(method: .post, path: "/api/v1/lists", body: .parameters(["title" => title]))
return Request<List>(method: .post, path: "/api/v1/lists", body: ParametersBody(["title" => title]))
}
// MARK: - Media
public static func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil) -> Request<Attachment> {
return Request<Attachment>(method: .post, path: "/api/v1/media", body: .formData([
return Request<Attachment>(method: .post, path: "/api/v1/media", body: FormDataBody([
"description" => description,
"focus" => focus
], attachment))
@ -259,7 +269,7 @@ public class Client {
}
public static func report(account: Account, statuses: [Status], comment: String) -> Request<Report> {
return Request<Report>(method: .post, path: "/api/v1/reports", body: .parameters([
return Request<Report>(method: .post, path: "/api/v1/reports", body: ParametersBody([
"account_id" => account.id,
"comment" => comment
] + "status_ids" => statuses.map { $0.id }))
@ -287,7 +297,7 @@ public class Client {
spoilerText: String? = nil,
visibility: Status.Visibility? = nil,
language: String? = nil) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses", body: .parameters([
return Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
"status" => text,
"content_type" => contentType.mimeType,
"in_reply_to_id" => inReplyTo,
@ -314,12 +324,32 @@ public class Client {
}
extension Client {
public enum Error: Swift.Error {
case unknownError
public enum Error: LocalizedError {
case networkError(Swift.Error)
case unexpectedStatus(Int)
case invalidRequest
case invalidResponse
case invalidModel
case mastodonError(String)
public var localizedDescription: String {
switch self {
case .networkError(let error):
return "Network Error: \(error.localizedDescription)"
// todo: support more status codes
case .unexpectedStatus(413):
return "HTTP 413: Payload Too Large"
case .unexpectedStatus(let code):
return "HTTP Code \(code)"
case .invalidRequest:
return "Invalid Request"
case .invalidResponse:
return "Invalid Response"
case .invalidModel:
return "Invalid Model"
case .mastodonError(let error):
return "Server Error: \(error)"
}
}
}
}

View File

@ -115,7 +115,7 @@ public final class Account: AccountProtocol, Decodable {
}
public static func mute(_ account: Account, notifications: Bool? = nil) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/mute", body: .parameters([
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/mute", body: ParametersBody([
"notifications" => notifications
]))
}

View File

@ -13,13 +13,14 @@ public class Attachment: Codable {
public let kind: Kind
public let url: URL
public let remoteURL: URL?
public let previewURL: URL
public let previewURL: URL?
public let textURL: URL?
public let meta: Metadata?
public let description: String?
public let blurHash: String?
public static func update(_ attachment: Attachment, focus: (Float, Float)?, description: String?) -> Request<Attachment> {
return Request<Attachment>(method: .put, path: "/api/v1/media/\(attachment.id)", body: .formData([
return Request<Attachment>(method: .put, path: "/api/v1/media/\(attachment.id)", body: FormDataBody([
"description" => (description ?? attachment.description),
"focus" => focus
], nil))
@ -30,11 +31,12 @@ public class Attachment: Codable {
self.id = try container.decode(String.self, forKey: .id)
self.kind = try container.decode(Kind.self, forKey: .kind)
self.url = try container.decode(URL.self, forKey: .url)
self.previewURL = try container.decode(URL.self, forKey: .previewURL)
self.remoteURL = try? container.decode(URL.self, forKey: .remoteURL)
self.textURL = try? container.decode(URL.self, forKey: .textURL)
self.meta = try? container.decode(Metadata.self, forKey: .meta)
self.description = try? container.decode(String.self, forKey: .description)
self.previewURL = try? container.decode(URL?.self, forKey: .previewURL)
self.remoteURL = try? container.decode(URL?.self, forKey: .remoteURL)
self.textURL = try? container.decode(URL?.self, forKey: .textURL)
self.meta = try? container.decode(Metadata?.self, forKey: .meta)
self.description = try? container.decode(String?.self, forKey: .description)
self.blurHash = try? container.decode(String?.self, forKey: .blurHash)
}
private enum CodingKeys: String, CodingKey {
@ -46,6 +48,7 @@ public class Attachment: Codable {
case textURL = "text_url"
case meta
case description
case blurHash = "blurhash"
}
}
@ -60,7 +63,7 @@ extension Attachment {
}
extension Attachment {
public class Metadata: Codable {
public struct Metadata: Codable {
public let length: String?
public let duration: Float?
public let audioEncoding: String?
@ -91,7 +94,7 @@ extension Attachment {
}
}
public class ImageMetadata: Codable {
public struct ImageMetadata: Codable {
public let width: Int?
public let height: Int?
public let size: String?

View File

@ -23,7 +23,7 @@ public class Filter: Decodable {
}
public static func update(_ filter: Filter, phrase: String? = nil, context: [Context]? = nil, irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
return Request<Filter>(method: .put, path: "/api/v1/filters/\(filter.id)", body: .parameters([
return Request<Filter>(method: .put, path: "/api/v1/filters/\(filter.id)", body: ParametersBody([
"phrase" => (phrase ?? filter.phrase),
"irreversible" => (irreversible ?? filter.irreversible),
"whole_word" => (wholeWord ?? filter.wholeWord),

View File

@ -8,7 +8,7 @@
import Foundation
public class List: Decodable {
public class List: Decodable, Equatable, Hashable {
public let id: String
public let title: String
@ -16,6 +16,14 @@ public class List: Decodable {
return .list(id: id)
}
public static func ==(lhs: List, rhs: List) -> Bool {
return lhs.id == rhs.id
}
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts")
request.range = range
@ -23,7 +31,7 @@ public class List: Decodable {
}
public static func update(_ list: List, title: String) -> Request<List> {
return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: .parameters(["title" => title]))
return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: ParametersBody(["title" => title]))
}
public static func delete(_ list: List) -> Request<Empty> {
@ -31,13 +39,13 @@ public class List: Decodable {
}
public static func add(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: .parameters(
return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
"account_ids" => accountIDs
))
}
public static func remove(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: .parameters(
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
"account_ids" => accountIDs
))
}

View File

@ -34,7 +34,7 @@ public class Notification: Decodable {
}
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
]))
}

View File

@ -8,56 +8,82 @@
import Foundation
enum Body {
case parameters([Parameter]?)
case formData([Parameter]?, FormAttachment?)
case empty
protocol Body {
var mimeType: String? { get }
var data: Data? { get }
}
extension Body {
private static let boundary: String = "PachydermBoundary"
struct EmptyBody: Body {
var mimeType: String? { nil }
var data: Data? { nil }
}
struct ParametersBody: Body {
let parameters: [Parameter]?
init(_ parmaeters: [Parameter]?) {
self.parameters = parmaeters
}
var mimeType: String? {
if parameters == nil || parameters!.isEmpty {
return nil
}
return "application/x-www-form-urlencoded; charset=utf-8"
}
var data: Data? {
switch self {
case let .parameters(parameters):
return parameters?.urlEncoded.data(using: .utf8)
case let .formData(parameters, attachment):
}
}
struct FormDataBody: Body {
private static let boundary = "PachydermBoundary"
let parameters: [Parameter]?
let attachment: FormAttachment?
init(_ parameters: [Parameter]?, _ attachment: FormAttachment?) {
self.parameters = parameters
self.attachment = attachment
}
var mimeType: String? {
if parameters == nil && attachment == nil {
return nil
}
return "multipart/form-data; boundary=\(FormDataBody.boundary)"
}
var data: Data? {
var data = Data()
parameters?.forEach { param in
guard let value = param.value else { return }
data.append("--\(Body.boundary)\r\n")
data.append("--\(FormDataBody.boundary)\r\n")
data.append("Content-Disposition: form-data; name=\"\(param.name)\"\r\n\r\n")
data.append("\(value)\r\n")
}
if let attachment = attachment {
data.append("--\(Body.boundary)\r\n")
data.append("--\(FormDataBody.boundary)\r\n")
data.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(attachment.fileName)\"\r\n")
data.append("Content-Type: \(attachment.mimeType)\r\n\r\n")
data.append(attachment.data)
data.append("\r\n")
}
data.append("--\(Body.boundary)--\r\n")
data.append("--\(FormDataBody.boundary)--\r\n")
return data
case .empty:
return nil
}
}
var mimeType: String? {
switch self {
case let .parameters(parameters):
if parameters == nil {
return nil
}
return "application/x-www-form-urlencoded; charset=utf-8"
case let .formData(parameters, attachment):
if parameters == nil && attachment == nil {
return nil
}
return "multipart/form-data; boundary=\(Body.boundary)"
case .empty:
return nil
}
}
}
struct JsonBody<T: Encodable>: Body {
let value: T
init(_ value: T) {
self.value = value
}
var mimeType: String? { "application/json" }
var data: Data? { try? Client.encoder.encode(value) }
}

View File

@ -14,7 +14,7 @@ public struct Request<ResultType: Decodable> {
let body: Body
var queryParameters: [Parameter]
init(method: Method, path: String, body: Body = .empty, queryParameters: [Parameter] = []) {
init(method: Method, path: String, body: Body = EmptyBody(), queryParameters: [Parameter] = []) {
self.method = method
self.path = path
self.body = body

View File

@ -10,5 +10,5 @@ import Foundation
public enum Response<Result: Decodable> {
case success(Result, Pagination?)
case failure(Error)
case failure(Client.Error)
}

View File

@ -22,16 +22,16 @@ public class InstanceSelector {
let request = URLRequest(url: url)
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
if let error = error {
completion(.failure(error))
completion(.failure(.networkError(error)))
return
}
guard let data = data,
let response = response as? HTTPURLResponse else {
completion(.failure(Client.Error.invalidResponse))
completion(.failure(.invalidResponse))
return
}
guard response.statusCode == 200 else {
completion(.failure(Client.Error.unknownError))
completion(.failure(.unexpectedStatus(response.statusCode)))
return
}
guard let result = try? decoder.decode([Instance].self, from: data) else {

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

View File

@ -20,7 +20,7 @@
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; };
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; };
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04ED00B021481ED800567C53 /* SteppedProgressView.swift */; };
D60309B52419D4F100A465FF /* ComposeAttachmentsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60309B42419D4F100A465FF /* ComposeAttachmentsViewController.swift */; };
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D60CFFDA24A290BA00D00083 /* SwiftSoup */; };
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */; };
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F232442372B005F8713 /* StatusMO.swift */; };
D60E2F292442372B005F8713 /* AccountMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F252442372B005F8713 /* AccountMO.swift */; };
@ -68,15 +68,20 @@
D6109A11214607D500432DC2 /* Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6109A10214607D500432DC2 /* Timeline.swift */; };
D611C2CF232DC61100C86A49 /* HashtagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */; };
D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */; };
D6163F2C21AA0AF1008DAC41 /* MyProfileTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6163F2B21AA0AF1008DAC41 /* MyProfileTableViewController.swift */; };
D61AC1D3232E928600C54D2D /* InstanceSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D2232E928600C54D2D /* InstanceSelector.swift */; };
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */; };
D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */; };
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */; };
D620483223D2A6A3008A63EF /* CompositionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483123D2A6A3008A63EF /* CompositionState.swift */; };
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */; };
D622757824EE133700B82A16 /* ComposeAssetPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757724EE133700B82A16 /* ComposeAssetPicker.swift */; };
D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */; };
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622759F24F1677200B82A16 /* ComposeHostingController.swift */; };
D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A524F1C81800B82A16 /* ComposeReplyView.swift */; };
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */; };
D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A924F1E01C00B82A16 /* ComposeTextView.swift */; };
D626493323BD751600612E6E /* ShowCameraCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D626493123BD751600612E6E /* ShowCameraCollectionViewCell.xib */; };
D626493523BD94CE00612E6E /* CompositionAttachmentData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */; };
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626493623C0FD0000612E6E /* AllPhotosTableViewCell.swift */; };
@ -111,12 +116,15 @@
D63569E023908A8D003DD353 /* StatusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60A4FFB238B726A008AC647 /* StatusState.swift */; };
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */; };
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */; };
D63F9C66241C4CC3004C03CF /* AddAttachmentTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D63F9C65241C4CC3004C03CF /* AddAttachmentTableViewCell.xib */; };
D63F9C68241C4F79004C03CF /* AddAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C67241C4F79004C03CF /* AddAttachmentTableViewCell.swift */; };
D63F9C6B241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C69241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift */; };
D63F9C6C241C50B9004C03CF /* ComposeAttachmentTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D63F9C6A241C50B9004C03CF /* ComposeAttachmentTableViewCell.xib */; };
D63F9C6E241D2D85004C03CF /* CompositionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */; };
D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */; };
D640D76922BAF5E6004FBE69 /* DomainBlocks.plist in Resources */ = {isa = PBXBuildFile; fileRef = D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */; };
D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */; };
D6412B0524B0227D00F5412E /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0424B0227D00F5412E /* ProfileViewController.swift */; };
D6412B0724B0237700F5412E /* ProfileStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0624B0237700F5412E /* ProfileStatusesViewController.swift */; };
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0824B0291E00F5412E /* MyProfileViewController.swift */; };
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */; };
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */; };
D641C773213CAA25004B4513 /* NotificationsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D641C772213CAA25004B4513 /* NotificationsTableViewController.swift */; };
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */; };
D6434EB3215B1856001A919A /* XCBRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6434EB2215B1856001A919A /* XCBRequest.swift */; };
@ -137,21 +145,17 @@
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */; };
D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */; };
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; };
D65A37F321472F300087646E /* SwiftSoup.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */; };
D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; };
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; };
D663625D2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D663625C2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib */; };
D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663625E2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift */; };
D663626221360B1900C9CBA2 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626121360B1900C9CBA2 /* Preferences.swift */; };
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626321360D2300C9CBA2 /* AvatarStyle.swift */; };
D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626B21361C6700C9CBA2 /* Account+Preferences.swift */; };
D66362712136338600C9CBA2 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66362702136338600C9CBA2 /* ComposeViewController.swift */; };
D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66362742137068A00C9CBA2 /* Visibility+Helpers.swift */; };
D667383C23299340000A2373 /* InstanceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667383B23299340000A2373 /* InstanceType.swift */; };
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */; };
D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D667E5E02134937B0057A976 /* TimelineStatusTableViewCell.xib */; };
D667E5E721349D4C0057A976 /* ProfileTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5E621349D4C0057A976 /* ProfileTableViewController.swift */; };
D667E5E921349EE50057A976 /* ProfileHeaderTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D667E5E821349EE50057A976 /* ProfileHeaderTableViewCell.xib */; };
D667E5EB21349EF80057A976 /* ProfileHeaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5EA21349EF80057A976 /* ProfileHeaderTableViewCell.swift */; };
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F02134D5050057A976 /* UIViewController+Delegates.swift */; };
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */; };
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */; };
@ -159,9 +163,14 @@
D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7B2157E01900721E32 /* XCBManager.swift */; };
D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */; };
D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A812157E8FA00721E32 /* XCBSession.swift */; };
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; };
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; };
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */; };
D677284E24ECC01D00C732D3 /* Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284D24ECC01D00C732D3 /* Draft.swift */; };
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */; };
D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */; };
D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D679C09E215850EF00DA27FE /* XCBActions.swift */; };
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67B506C250B291200FAECFB /* BlurHashDecode.swift */; };
D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */; };
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */; };
D67C57B221E28FAD00C3118B /* ComposeStatusReplyView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D67C57B121E28FAD00C3118B /* ComposeStatusReplyView.xib */; };
@ -174,7 +183,11 @@
D681E4D7246E32290053414F /* StatusActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D6246E32290053414F /* StatusActivityItemSource.swift */; };
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */; };
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */; };
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */; };
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; };
D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525C24A3E8F00054355A /* SearchViewController.swift */; };
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */; };
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D690797224A4EF9700023A34 /* UIBezierPath+Helpers.swift */; };
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */; };
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5823FE24300061E07D /* InteractivePushTransition.swift */; };
D6945C2F23AC47C3005C403C /* SavedDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6945C2E23AC47C3005C403C /* SavedDataManager.swift */; };
@ -200,7 +213,6 @@
D6A5BB2D23BBA9C4003BF21D /* MyProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */; };
D6A5BB2F23BBAC97003BF21D /* DelegatingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */; };
D6A5BB3123BBAD87003BF21D /* JSONResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB3023BBAD87003BF21D /* JSONResponse.swift */; };
D6A5FAF1217B7E05003DB2D9 /* ComposeViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A5FAF0217B7E05003DB2D9 /* ComposeViewController.xib */; };
D6AC956723C4347E008C9946 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AC956623C4347E008C9946 /* SceneDelegate.swift */; };
D6ACE1AC240C3BAD004EA8E2 /* Ambassador.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D65F613023AE99E000F3CFD3 /* Ambassador.framework */; };
D6ACE1AD240C3BAD004EA8E2 /* Embassy.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D65F612D23AE990C00F3CFD3 /* Embassy.framework */; };
@ -217,6 +229,7 @@
D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053A923BD2F1400A066FA /* AssetCollectionViewCell.swift */; };
D6B053AC23BD2F1400A066FA /* AssetCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6B053AA23BD2F1400A066FA /* AssetCollectionViewCell.xib */; };
D6B053AE23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053AD23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift */; };
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */; };
D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */; };
D6BC874521961F73006163F1 /* Gifu.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6BC874421961F73006163F1 /* Gifu.framework */; };
D6BC874621961F73006163F1 /* Gifu.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D6BC874421961F73006163F1 /* Gifu.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
@ -226,7 +239,6 @@
D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */; };
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; };
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */; };
D6BED170212663DA00F02DA0 /* SwiftSoup.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; };
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; };
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; };
@ -234,8 +246,15 @@
D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C7D27C22B6EBF800071952 /* AttachmentsContainerView.swift */; };
D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C94D862139E62700CB5196 /* LargeImageViewController.swift */; };
D6C94D892139E6EC00CB5196 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C94D882139E6EC00CB5196 /* AttachmentView.swift */; };
D6C99FC724FACFAB005C74D3 /* ActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */; };
D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */; };
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; };
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; };
D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */; };
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDDF24F41B8400FF50A5 /* ComposeContainerView.swift */; };
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; };
D6D4CC91250D2C3100FCCF8D /* UIAccessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC90250D2C3100FCCF8D /* UIAccessibility.swift */; };
D6D4CC94250DB86A00FCCF8D /* ComposeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */; };
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */; };
D6D4DDD7212518A200E1C4BB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D6D4DDD6212518A200E1C4BB /* Assets.xcassets */; };
D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */; };
@ -250,6 +269,8 @@
D6E6F26521604242006A8599 /* CharacterCounterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26421604242006A8599 /* CharacterCounterTests.swift */; };
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; };
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */; };
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; };
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; };
D6F1F84D2193B56E00F5FE67 /* Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F1F84C2193B56E00F5FE67 /* Cache.swift */; };
D6F2E965249E8BFD005846BB /* CrashReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* CrashReporterViewController.swift */; };
D6F2E966249E8BFD005846BB /* CrashReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* CrashReporterViewController.xib */; };
@ -303,7 +324,6 @@
dstSubfolderSpec = 10;
files = (
D61099C12144B0CC00432DC2 /* Pachyderm.framework in Embed Frameworks */,
D6BED170212663DA00F02DA0 /* SwiftSoup.framework in Embed Frameworks */,
D6BC874621961F73006163F1 /* Gifu.framework in Embed Frameworks */,
0461A3912163CBAE00C0A807 /* Cache.framework in Embed Frameworks */,
);
@ -325,7 +345,6 @@
04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarViewController.swift; sourceTree = "<group>"; };
04DACE8D212CC7CC009840C4 /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = "<group>"; };
04ED00B021481ED800567C53 /* SteppedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteppedProgressView.swift; sourceTree = "<group>"; };
D60309B42419D4F100A465FF /* ComposeAttachmentsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentsViewController.swift; sourceTree = "<group>"; };
D60A4FFB238B726A008AC647 /* StatusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusState.swift; sourceTree = "<group>"; };
D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseStatusTableViewCell.swift; sourceTree = "<group>"; };
D60E2F232442372B005F8713 /* StatusMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMO.swift; sourceTree = "<group>"; };
@ -375,15 +394,20 @@
D6109A10214607D500432DC2 /* Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timeline.swift; sourceTree = "<group>"; };
D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTableViewCell.swift; sourceTree = "<group>"; };
D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = HashtagTableViewCell.xib; sourceTree = "<group>"; };
D6163F2B21AA0AF1008DAC41 /* MyProfileTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileTableViewController.swift; sourceTree = "<group>"; };
D61AC1D2232E928600C54D2D /* InstanceSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelector.swift; sourceTree = "<group>"; };
D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelectorTableViewController.swift; sourceTree = "<group>"; };
D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceTableViewCell.swift; sourceTree = "<group>"; };
D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InstanceTableViewCell.xib; sourceTree = "<group>"; };
D620483123D2A6A3008A63EF /* CompositionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionState.swift; sourceTree = "<group>"; };
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentsList.swift; sourceTree = "<group>"; };
D622757724EE133700B82A16 /* ComposeAssetPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAssetPicker.swift; sourceTree = "<group>"; };
D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentRow.swift; sourceTree = "<group>"; };
D622759F24F1677200B82A16 /* ComposeHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeHostingController.swift; sourceTree = "<group>"; };
D62275A524F1C81800B82A16 /* ComposeReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyView.swift; sourceTree = "<group>"; };
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyContentView.swift; sourceTree = "<group>"; };
D62275A924F1E01C00B82A16 /* ComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTextView.swift; sourceTree = "<group>"; };
D626493123BD751600612E6E /* ShowCameraCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ShowCameraCollectionViewCell.xib; sourceTree = "<group>"; };
D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachmentData.swift; sourceTree = "<group>"; };
D626493623C0FD0000612E6E /* AllPhotosTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllPhotosTableViewCell.swift; sourceTree = "<group>"; };
@ -417,12 +441,15 @@
D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = "<group>"; };
D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesNavigationController.swift; sourceTree = "<group>"; };
D6370B9B24421FF30092A7FF /* Tusker.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Tusker.xcdatamodel; sourceTree = "<group>"; };
D63F9C65241C4CC3004C03CF /* AddAttachmentTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AddAttachmentTableViewCell.xib; sourceTree = "<group>"; };
D63F9C67241C4F79004C03CF /* AddAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAttachmentTableViewCell.swift; sourceTree = "<group>"; };
D63F9C69241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentTableViewCell.swift; sourceTree = "<group>"; };
D63F9C6A241C50B9004C03CF /* ComposeAttachmentTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ComposeAttachmentTableViewCell.xib; sourceTree = "<group>"; };
D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachment.swift; sourceTree = "<group>"; };
D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectImageButton.swift; sourceTree = "<group>"; };
D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = DomainBlocks.plist; sourceTree = "<group>"; };
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarScrollableViewController.swift; sourceTree = "<group>"; };
D6412B0424B0227D00F5412E /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
D6412B0624B0237700F5412E /* ProfileStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusesViewController.swift; sourceTree = "<group>"; };
D6412B0824B0291E00F5412E /* MyProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileViewController.swift; sourceTree = "<group>"; };
D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ProfileHeaderView.xib; sourceTree = "<group>"; };
D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = "<group>"; };
D641C772213CAA25004B4513 /* NotificationsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsTableViewController.swift; sourceTree = "<group>"; };
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTextAttachment.swift; sourceTree = "<group>"; };
D6434EB2215B1856001A919A /* XCBRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBRequest.swift; sourceTree = "<group>"; };
@ -448,19 +475,16 @@
D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTests.swift; sourceTree = "<group>"; };
D65F613523AFD65900F3CFD3 /* Ambassador.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Ambassador.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D65F613723AFD65D00F3CFD3 /* Embassy.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Embassy.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusStateResolver.swift; sourceTree = "<group>"; };
D663625C2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConversationMainStatusTableViewCell.xib; sourceTree = "<group>"; };
D663625E2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationMainStatusTableViewCell.swift; sourceTree = "<group>"; };
D663626121360B1900C9CBA2 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
D663626321360D2300C9CBA2 /* AvatarStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStyle.swift; sourceTree = "<group>"; };
D663626B21361C6700C9CBA2 /* Account+Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Preferences.swift"; sourceTree = "<group>"; };
D66362702136338600C9CBA2 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = "<group>"; };
D66362742137068A00C9CBA2 /* Visibility+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Visibility+Helpers.swift"; sourceTree = "<group>"; };
D667383B23299340000A2373 /* InstanceType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceType.swift; sourceTree = "<group>"; };
D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppShortcutItems.swift; sourceTree = "<group>"; };
D667E5E02134937B0057A976 /* TimelineStatusTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TimelineStatusTableViewCell.xib; sourceTree = "<group>"; };
D667E5E621349D4C0057A976 /* ProfileTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileTableViewController.swift; sourceTree = "<group>"; };
D667E5E821349EE50057A976 /* ProfileHeaderTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ProfileHeaderTableViewCell.xib; sourceTree = "<group>"; };
D667E5EA21349EF80057A976 /* ProfileHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderTableViewCell.swift; sourceTree = "<group>"; };
D667E5F02134D5050057A976 /* UIViewController+Delegates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Delegates.swift"; sourceTree = "<group>"; };
D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTableViewController.swift; sourceTree = "<group>"; };
D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Equatable.swift"; sourceTree = "<group>"; };
@ -468,9 +492,14 @@
D6757A7B2157E01900721E32 /* XCBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBManager.swift; sourceTree = "<group>"; };
D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBRequestSpec.swift; sourceTree = "<group>"; };
D6757A812157E8FA00721E32 /* XCBSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBSession.swift; sourceTree = "<group>"; };
D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = "<group>"; };
D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAvatarImageView.swift; sourceTree = "<group>"; };
D677284D24ECC01D00C732D3 /* Draft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Draft.swift; sourceTree = "<group>"; };
D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PKDrawing+Render.swift"; sourceTree = "<group>"; };
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAccountAvatarView.swift; sourceTree = "<group>"; };
D679C09E215850EF00DA27FE /* XCBActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBActions.swift; sourceTree = "<group>"; };
D67B506C250B291200FAECFB /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = "<group>"; };
D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeAccountDetailView.swift; sourceTree = "<group>"; };
D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Uniques.swift"; sourceTree = "<group>"; };
D67C57B121E28FAD00C3118B /* ComposeStatusReplyView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ComposeStatusReplyView.xib; sourceTree = "<group>"; };
@ -483,7 +512,11 @@
D681E4D6246E32290053414F /* StatusActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActivityItemSource.swift; sourceTree = "<group>"; };
D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountActivityItemSource.swift; sourceTree = "<group>"; };
D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeDrawingViewController.swift; sourceTree = "<group>"; };
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedProgressView.swift; sourceTree = "<group>"; };
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = "<group>"; };
D68E525C24A3E8F00054355A /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = "<group>"; };
D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.swift; sourceTree = "<group>"; };
D690797224A4EF9700023A34 /* UIBezierPath+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBezierPath+Helpers.swift"; sourceTree = "<group>"; };
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedNavigationViewController.swift; sourceTree = "<group>"; };
D693DE5823FE24300061E07D /* InteractivePushTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractivePushTransition.swift; sourceTree = "<group>"; };
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedDataManager.swift; sourceTree = "<group>"; };
@ -508,7 +541,6 @@
D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileTests.swift; sourceTree = "<group>"; };
D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelegatingResponse.swift; sourceTree = "<group>"; };
D6A5BB3023BBAD87003BF21D /* JSONResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONResponse.swift; sourceTree = "<group>"; };
D6A5FAF0217B7E05003DB2D9 /* ComposeViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ComposeViewController.xib; sourceTree = "<group>"; };
D6AC956623C4347E008C9946 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
D6AEBB3D2321638100E5038B /* UIActivity+Types.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIActivity+Types.swift"; sourceTree = "<group>"; };
D6AEBB402321642700E5038B /* SendMesasgeActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMesasgeActivity.swift; sourceTree = "<group>"; };
@ -522,6 +554,7 @@
D6B053A923BD2F1400A066FA /* AssetCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetCollectionViewCell.swift; sourceTree = "<group>"; };
D6B053AA23BD2F1400A066FA /* AssetCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AssetCollectionViewCell.xib; sourceTree = "<group>"; };
D6B053AD23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerSheetContainerViewController.swift; sourceTree = "<group>"; };
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayNameLabel.swift; sourceTree = "<group>"; };
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Visibility.swift"; sourceTree = "<group>"; };
D6BC874421961F73006163F1 /* Gifu.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Gifu.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedTableViewController.swift; sourceTree = "<group>"; };
@ -530,7 +563,6 @@
D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsMode.swift; sourceTree = "<group>"; };
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = "<group>"; };
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = "<group>"; };
D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SwiftSoup.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; };
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = "<group>"; };
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; };
@ -538,8 +570,15 @@
D6C7D27C22B6EBF800071952 /* AttachmentsContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentsContainerView.swift; sourceTree = "<group>"; };
D6C94D862139E62700CB5196 /* LargeImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageViewController.swift; sourceTree = "<group>"; };
D6C94D882139E6EC00CB5196 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = "<group>"; };
D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorView.swift; sourceTree = "<group>"; };
D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainComposeTextView.swift; sourceTree = "<group>"; };
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; };
D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; };
D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = "<group>"; };
D6D3FDDF24F41B8400FF50A5 /* ComposeContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeContainerView.swift; sourceTree = "<group>"; };
D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = "<group>"; };
D6D4CC90250D2C3100FCCF8D /* UIAccessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAccessibility.swift; sourceTree = "<group>"; };
D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTests.swift; sourceTree = "<group>"; };
D6D4DDCC212518A000E1C4BB /* Tusker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tusker.app; sourceTree = BUILT_PRODUCTS_DIR; };
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
D6D4DDD6212518A200E1C4BB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@ -556,16 +595,18 @@
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadScrollGestureRecognizer.swift; sourceTree = "<group>"; };
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakArray.swift; sourceTree = "<group>"; };
D6E0DC8D216EDF1E00369478 /* Previewing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Previewing.swift; sourceTree = "<group>"; };
D6E4885C24A2890C0011C13E /* Tusker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Tusker.entitlements; sourceTree = "<group>"; };
D6E6F26221603F8B006A8599 /* CharacterCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCounter.swift; sourceTree = "<group>"; };
D6E6F26421604242006A8599 /* CharacterCounterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCounterTests.swift; sourceTree = "<group>"; };
D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = "<group>"; };
D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISceneSession+MastodonController.swift"; sourceTree = "<group>"; };
D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSplitViewController.swift; sourceTree = "<group>"; };
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = "<group>"; };
D6F1F84C2193B56E00F5FE67 /* Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cache.swift; sourceTree = "<group>"; };
D6F2E963249E8BFD005846BB /* CrashReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashReporterViewController.swift; sourceTree = "<group>"; };
D6F2E964249E8BFD005846BB /* CrashReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CrashReporterViewController.xib; sourceTree = "<group>"; };
D6F953EB212519E700CF0F2B /* TimelineTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewController.swift; sourceTree = "<group>"; };
D6F953EF21251A2900CF0F2B /* MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonController.swift; sourceTree = "<group>"; };
D6F98BD523AE951F008A4DAC /* Swifter.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Swifter.framework; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -590,10 +631,10 @@
files = (
D61099C02144B0CC00432DC2 /* Pachyderm.framework in Frameworks */,
D6B0539F23BD2BA300A066FA /* SheetController in Frameworks */,
D65A37F321472F300087646E /* SwiftSoup.framework in Frameworks */,
D69CCBBF249E6EFD000AF167 /* CrashReporter in Frameworks */,
D6BC874521961F73006163F1 /* Gifu.framework in Frameworks */,
0461A3902163CBAE00C0A807 /* Cache.framework in Frameworks */,
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -741,24 +782,14 @@
path = "Draft Cell";
sourceTree = "<group>";
};
D61959D1241E844900A37B8E /* Attachment Cells */ = {
isa = PBXGroup;
children = (
D63F9C67241C4F79004C03CF /* AddAttachmentTableViewCell.swift */,
D63F9C65241C4CC3004C03CF /* AddAttachmentTableViewCell.xib */,
D63F9C69241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift */,
D63F9C6A241C50B9004C03CF /* ComposeAttachmentTableViewCell.xib */,
);
path = "Attachment Cells";
sourceTree = "<group>";
};
D61959D2241E846D00A37B8E /* Models */ = {
isa = PBXGroup;
children = (
D6285B5221EA708700FE4B39 /* StatusFormat.swift */,
D620483123D2A6A3008A63EF /* CompositionState.swift */,
D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */,
D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */,
D677284D24ECC01D00C732D3 /* Draft.swift */,
D627FF75217E923E00CC0648 /* DraftsManager.swift */,
);
path = Models;
sourceTree = "<group>";
@ -899,7 +930,10 @@
D641C782213DD7F0004B4513 /* Main */ = {
isa = PBXGroup;
children = (
D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */,
04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */,
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */,
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */,
);
path = Main;
sourceTree = "<group>";
@ -916,8 +950,9 @@
D641C784213DD819004B4513 /* Profile */ = {
isa = PBXGroup;
children = (
D667E5E621349D4C0057A976 /* ProfileTableViewController.swift */,
D6163F2B21AA0AF1008DAC41 /* MyProfileTableViewController.swift */,
D6412B0424B0227D00F5412E /* ProfileViewController.swift */,
D6412B0624B0237700F5412E /* ProfileStatusesViewController.swift */,
D6412B0824B0291E00F5412E /* MyProfileViewController.swift */,
);
path = Profile;
sourceTree = "<group>";
@ -942,10 +977,20 @@
D641C787213DD862004B4513 /* Compose */ = {
isa = PBXGroup;
children = (
D6A5FAF0217B7E05003DB2D9 /* ComposeViewController.xib */,
D66362702136338600C9CBA2 /* ComposeViewController.swift */,
D60309B42419D4F100A465FF /* ComposeAttachmentsViewController.swift */,
D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */,
D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */,
D622759F24F1677200B82A16 /* ComposeHostingController.swift */,
D6D3FDDF24F41B8400FF50A5 /* ComposeContainerView.swift */,
D677284724ECBCB100C732D3 /* ComposeView.swift */,
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */,
D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */,
D62275A924F1E01C00B82A16 /* ComposeTextView.swift */,
D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */,
D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */,
D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */,
D622757724EE133700B82A16 /* ComposeAssetPicker.swift */,
D62275A524F1C81800B82A16 /* ComposeReplyView.swift */,
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */,
);
path = Compose;
sourceTree = "<group>";
@ -994,8 +1039,8 @@
D641C78B213DD92F004B4513 /* Profile Header */ = {
isa = PBXGroup;
children = (
D667E5E821349EE50057A976 /* ProfileHeaderTableViewCell.xib */,
D667E5EA21349EF80057A976 /* ProfileHeaderTableViewCell.swift */,
D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */,
D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */,
);
path = "Profile Header";
sourceTree = "<group>";
@ -1031,7 +1076,6 @@
D65F613523AFD65900F3CFD3 /* Ambassador.framework */,
D65F613023AE99E000F3CFD3 /* Ambassador.framework */,
D65F612D23AE990C00F3CFD3 /* Embassy.framework */,
D6F98BD523AE951F008A4DAC /* Swifter.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@ -1064,6 +1108,10 @@
D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */,
D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */,
D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */,
D690797224A4EF9700023A34 /* UIBezierPath+Helpers.swift */,
D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */,
D6D4CC90250D2C3100FCCF8D /* UIAccessibility.swift */,
D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -1081,6 +1129,14 @@
path = XCallbackURL;
sourceTree = "<group>";
};
D67B506B250B28FF00FAECFB /* Vendor */ = {
isa = PBXGroup;
children = (
D67B506C250B291200FAECFB /* BlurHashDecode.swift */,
);
path = Vendor;
sourceTree = "<group>";
};
D67C57A721E2649B00C3118B /* Account Detail */ = {
isa = PBXGroup;
children = (
@ -1194,6 +1250,7 @@
D6BC9DD8232D8BCA002CA326 /* Search */ = {
isa = PBXGroup;
children = (
D68E525C24A3E8F00054355A /* SearchViewController.swift */,
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */,
);
path = Search;
@ -1211,10 +1268,13 @@
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */,
D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */,
D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */,
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */,
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
D67C57A721E2649B00C3118B /* Account Detail */,
D67C57B021E28F9400C3118B /* Compose Status Reply */,
D626494023C122C800612E6E /* Asset Picker */,
D61959D1241E844900A37B8E /* Attachment Cells */,
D61959D0241E842400A37B8E /* Draft Cell */,
D641C78A213DD926004B4513 /* Status */,
D6C7D27B22B6EBE200071952 /* Attachments */,
@ -1240,6 +1300,7 @@
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */,
D693DE5823FE24300061E07D /* InteractivePushTransition.swift */,
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */,
);
path = Utilities;
sourceTree = "<group>";
@ -1259,7 +1320,6 @@
children = (
D6BC874421961F73006163F1 /* Gifu.framework */,
0461A38F2163CBAE00C0A807 /* Cache.framework */,
D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */,
D61099AC2144B0CC00432DC2 /* Pachyderm */,
D61099B92144B0CC00432DC2 /* PachydermTests */,
D6D4DDCE212518A000E1C4BB /* Tusker */,
@ -1285,16 +1345,17 @@
D6D4DDCE212518A000E1C4BB /* Tusker */ = {
isa = PBXGroup;
children = (
D6E4885C24A2890C0011C13E /* Tusker.entitlements */,
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
D6AC956623C4347E008C9946 /* SceneDelegate.swift */,
D64D0AAC2128D88B005A6F37 /* LocalData.swift */,
D627FF75217E923E00CC0648 /* DraftsManager.swift */,
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */,
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */,
D64D8CA82463B494006B0BAA /* CachedDictionary.swift */,
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
D67B506B250B28FF00FAECFB /* Vendor */,
D6F1F84E2193B9BE00F5FE67 /* Caching */,
D6757A7A2157E00100721E32 /* XCallbackURL */,
D62D241E217AA46B005076CC /* Shortcuts */,
@ -1331,6 +1392,7 @@
D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */,
D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */,
D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */,
D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */,
D6A5BB2623BAC88E003BF21D /* Preferences */,
D6D4DDF1212518A200E1C4BB /* Info.plist */,
);
@ -1433,6 +1495,7 @@
packageProductDependencies = (
D6B0539E23BD2BA300A066FA /* SheetController */,
D69CCBBE249E6EFD000AF167 /* CrashReporter */,
D60CFFDA24A290BA00D00083 /* SwiftSoup */,
);
productName = Tusker;
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
@ -1482,7 +1545,7 @@
D6D4DDC4212518A000E1C4BB /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 1000;
LastSwiftUpdateCheck = 1200;
LastUpgradeCheck = 1020;
ORGANIZATIONNAME = Shadowfacts;
TargetAttributes = {
@ -1497,7 +1560,7 @@
};
D6D4DDCB212518A000E1C4BB = {
CreatedOnToolsVersion = 10.0;
LastSwiftMigration = 1020;
LastSwiftMigration = 1200;
};
D6D4DDDF212518A200E1C4BB = {
CreatedOnToolsVersion = 10.0;
@ -1529,6 +1592,7 @@
packageReferences = (
D6B0539D23BD2BA300A066FA /* XCRemoteSwiftPackageReference "SheetController" */,
D69CCBBD249E6EFD000AF167 /* XCRemoteSwiftPackageReference "plcrashreporter" */,
D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
);
productRefGroup = D6D4DDCD212518A000E1C4BB /* Products */;
projectDirPath = "";
@ -1568,7 +1632,7 @@
D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */,
D627FF7D217E958900CC0648 /* DraftTableViewCell.xib in Resources */,
D6A3BC7D232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib in Resources */,
D667E5E921349EE50057A976 /* ProfileHeaderTableViewCell.xib in Resources */,
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */,
D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */,
D6B053AC23BD2F1400A066FA /* AssetCollectionViewCell.xib in Resources */,
D6D4DDD7212518A200E1C4BB /* Assets.xcassets in Resources */,
@ -1582,11 +1646,8 @@
D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */,
D64BC19023C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib in Resources */,
D67C57B221E28FAD00C3118B /* ComposeStatusReplyView.xib in Resources */,
D63F9C6C241C50B9004C03CF /* ComposeAttachmentTableViewCell.xib in Resources */,
D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */,
D63F9C66241C4CC3004C03CF /* AddAttachmentTableViewCell.xib in Resources */,
D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */,
D6A5FAF1217B7E05003DB2D9 /* ComposeViewController.xib in Resources */,
D6F2E966249E8BFD005846BB /* CrashReporterViewController.xib in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -1701,10 +1762,13 @@
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */,
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */,
D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */,
D60E2F292442372B005F8713 /* AccountMO.swift in Sources */,
D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */,
D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */,
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */,
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */,
D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */,
D6AEBB412321642700E5038B /* SendMesasgeActivity.swift in Sources */,
D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */,
D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */,
@ -1714,18 +1778,21 @@
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */,
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */,
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */,
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */,
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */,
D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */,
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */,
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */,
D6C99FC724FACFAB005C74D3 /* ActivityIndicatorView.swift in Sources */,
D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */,
0411610022B442870030A9B7 /* LoadingLargeImageViewController.swift in Sources */,
D6412B0724B0237700F5412E /* ProfileStatusesViewController.swift in Sources */,
D611C2CF232DC61100C86A49 /* HashtagTableViewCell.swift in Sources */,
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */,
D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */,
D627943723A552C200D38C68 /* BookmarkStatusActivity.swift in Sources */,
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
D66362712136338600C9CBA2 /* ComposeViewController.swift in Sources */,
D6AC956723C4347E008C9946 /* SceneDelegate.swift in Sources */,
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */,
D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */,
@ -1735,12 +1802,13 @@
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */,
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */,
D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */,
D60309B52419D4F100A465FF /* ComposeAttachmentsViewController.swift in Sources */,
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */,
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */,
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */,
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */,
D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */,
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */,
D6A3BC852321F6C100FD64D5 /* AccountListTableViewController.swift in Sources */,
@ -1749,20 +1817,25 @@
D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */,
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */,
D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */,
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
D67C57B421E2910700C3118B /* ComposeStatusReplyView.swift in Sources */,
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
D627FF7B217E951500CC0648 /* DraftsTableViewController.swift in Sources */,
D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */,
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */,
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
D6A3BC8F2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift in Sources */,
D6AEBB4823216B1D00E5038B /* AccountActivity.swift in Sources */,
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */,
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */,
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */,
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */,
D63F9C6B241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift in Sources */,
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */,
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */,
D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */,
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */,
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */,
@ -1775,6 +1848,8 @@
D6AEBB4523216AF800E5038B /* FollowAccountActivity.swift in Sources */,
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */,
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */,
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */,
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */,
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */,
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */,
@ -1785,8 +1860,10 @@
D6B053AE23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift in Sources */,
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */,
D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */,
D6D4CC91250D2C3100FCCF8D /* UIAccessibility.swift in Sources */,
D627943E23A564D400D38C68 /* ExploreViewController.swift in Sources */,
D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */,
D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */,
D6434EB3215B1856001A919A /* XCBRequest.swift in Sources */,
D681E4D3246E2AFF0053414F /* MuteConversationActivity.swift in Sources */,
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */,
@ -1795,16 +1872,19 @@
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
D627943523A5525100D38C68 /* StatusActivity.swift in Sources */,
D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */,
D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */,
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */,
D622757824EE133700B82A16 /* ComposeAssetPicker.swift in Sources */,
D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */,
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */,
D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */,
D63F9C68241C4F79004C03CF /* AddAttachmentTableViewCell.swift in Sources */,
D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */,
D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */,
D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */,
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
@ -1823,34 +1903,39 @@
D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */,
D6A3BC7C232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift in Sources */,
D6F953EC212519E700CF0F2B /* TimelineTableViewController.swift in Sources */,
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
D620483223D2A6A3008A63EF /* CompositionState.swift in Sources */,
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */,
D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */,
D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */,
D667E5EB21349EF80057A976 /* ProfileHeaderTableViewCell.swift in Sources */,
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */,
04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */,
D641C773213CAA25004B4513 /* NotificationsTableViewController.swift in Sources */,
D6412B0524B0227D00F5412E /* ProfileViewController.swift in Sources */,
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */,
D64D8CA92463B494006B0BAA /* CachedDictionary.swift in Sources */,
D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */,
D6F1F84D2193B56E00F5FE67 /* Cache.swift in Sources */,
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,
0427037C22B316B9000D31B6 /* SilentActionPrefs.swift in Sources */,
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */,
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */,
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */,
D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */,
D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */,
D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */,
D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */,
D677284E24ECC01D00C732D3 /* Draft.swift in Sources */,
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,
D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */,
D667E5E721349D4C0057A976 /* ProfileTableViewController.swift in Sources */,
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */,
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */,
D6163F2C21AA0AF1008DAC41 /* MyProfileTableViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -1872,6 +1957,7 @@
D6A5BB2F23BBAC97003BF21D /* DelegatingResponse.swift in Sources */,
D6A5BB2D23BBA9C4003BF21D /* MyProfileTests.swift in Sources */,
D6A5BB2B23BAEF61003BF21D /* APIMocks.swift in Sources */,
D6D4CC94250DB86A00FCCF8D /* ComposeTests.swift in Sources */,
D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -2069,7 +2155,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
@ -2125,7 +2211,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
@ -2140,23 +2226,27 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6;
CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2020.1;
OTHER_LDFLAGS = "";
"OTHER_SWIFT_FLAGS[sdk=iphone*14*]" = "$(inherited) -D SDK_IOS_14";
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTS_MACCATALYST = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = "1,2,6";
};
name = Debug;
};
@ -2165,22 +2255,26 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6;
CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2020.1;
"OTHER_SWIFT_FLAGS[sdk=iphone*14*]" = "$(inherited) -D SDK_IOS_14";
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTS_MACCATALYST = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = "1,2,6";
};
name = Release;
};
@ -2326,6 +2420,14 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/scinfu/SwiftSoup.git";
requirement = {
kind = upToNextMinorVersion;
minimumVersion = 2.3.2;
};
};
D69CCBBD249E6EFD000AF167 /* XCRemoteSwiftPackageReference "plcrashreporter" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/microsoft/plcrashreporter";
@ -2345,6 +2447,11 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
D60CFFDA24A290BA00D00083 /* SwiftSoup */ = {
isa = XCSwiftPackageProductDependency;
package = D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
productName = SwiftSoup;
};
D69CCBBE249E6EFD000AF167 /* CrashReporter */ = {
isa = XCSwiftPackageProductDependency;
package = D69CCBBD249E6EFD000AF167 /* XCRemoteSwiftPackageReference "plcrashreporter" */;

View File

@ -27,15 +27,6 @@
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D6D4DDCB212518A000E1C4BB"
BuildableName = "Tusker.app"
BlueprintName = "Tusker"
ReferencedContainer = "container:Tusker.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO">

View File

@ -10,9 +10,6 @@
<FileRef
location = "group:Cache/Cache.xcodeproj">
</FileRef>
<FileRef
location = "group:SwiftSoup/SwiftSoup.xcodeproj">
</FileRef>
<FileRef
location = "group:Gifu/Gifu.xcodeproj">
</FileRef>

View File

@ -6,8 +6,26 @@
"repositoryURL": "https://github.com/microsoft/plcrashreporter",
"state": {
"branch": null,
"revision": "4637a7854de2cc5c354d46fb931d74bdbc2c043e",
"version": "1.7.0"
"revision": "6b7ca9a2faad6ea990ff60b0a3ee4fdf3db59150",
"version": "1.7.2"
}
},
{
"package": "SheetController",
"repositoryURL": "https://git.shadowfacts.net/shadowfacts/SheetController.git",
"state": {
"branch": "master",
"revision": "aa0f5192eaf19d01c89dbfa9ec5878a700376f23",
"version": null
}
},
{
"package": "SwiftSoup",
"repositoryURL": "https://github.com/scinfu/SwiftSoup.git",
"state": {
"branch": null,
"revision": "774dc9c7213085db8aa59595e27c1cd22e428904",
"version": "2.3.2"
}
}
]

View File

@ -28,7 +28,9 @@ class SendMessageActivity: AccountActivity {
override var activityViewController: UIViewController? {
guard let account = account else { return nil }
return UINavigationController(rootViewController: ComposeViewController(mentioningAcct: account.acct, mastodonController: mastodonController))
let draft = mastodonController.createDraft(mentioningAcct: account.acct)
let compose = ComposeHostingController(draft: draft, mastodonController: mastodonController)
return UINavigationController(rootViewController: compose)
}
}

View File

@ -36,10 +36,10 @@ class OpenInSafariActivity: UIActivity {
activityDidFinish(true)
}
static func completionHandler(viewController: UIViewController, url: URL) -> UIActivityViewController.CompletionWithItemsHandler {
static func completionHandler(navigator: TuskerNavigationDelegate, url: URL) -> UIActivityViewController.CompletionWithItemsHandler {
return { (activityType, _, _, _) in
if activityType == .openInSafari {
viewController.present(SFSafariViewController(url: url), animated: true)
navigator.show(SFSafariViewController(url: url))
}
}
}

View File

@ -47,4 +47,15 @@ enum Cache<T> {
try hybrid.setObject(object, forKey: key, expiry: expiry)
}
}
func removeAll() throws {
switch self {
case let .memory(memory):
memory.removeAll()
case let .disk(disk):
try disk.removeAll()
case let .hybrid(hybrid):
try hybrid.removeAll()
}
}
}

View File

@ -16,7 +16,7 @@ class ImageCache {
static let attachments = ImageCache(name: "Attachments", memoryExpiry: .seconds(60 * 2))
static let emojis = ImageCache(name: "Emojis", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60))
let cache: Cache<Data>
private let cache: Cache<Data>
private var groups = [URL: RequestGroup]()
@ -68,6 +68,10 @@ class ImageCache {
groups[url]?.cancelWithoutCallback()
}
func reset() throws {
try cache.removeAll()
}
private class RequestGroup {
let url: URL
private let onFinished: (Data?) -> Void

View File

@ -45,6 +45,10 @@ class MastodonController {
var account: Account!
var instance: Instance!
var loggedIn: Bool {
accountInfo != nil
}
init(instanceURL: URL, transient: Bool = false) {
self.instanceURL = instanceURL
self.accountInfo = nil
@ -116,3 +120,6 @@ class MastodonController {
}
}
// ObservableObject so that SwiftUI views can receive it through @EnvironmentObject
extension MastodonController: ObservableObject {}

View File

@ -124,9 +124,19 @@ extension StatusMO {
self.uri = status.uri
self.url = status.url
self.visibility = status.visibility
self.account = container.account(for: status.account.id, in: context) ?? AccountMO(apiAccount: status.account, container: container, context: context)
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 {
self.reblog = container.status(for: reblog.id, in: context) ?? StatusMO(apiStatus: reblog, container: container, context: context)
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
}

View File

@ -1,87 +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: [CompositionAttachment]) -> 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?
var attachments: [CompositionAttachment]
private(set) var inReplyToID: String?
private(set) var lastModified: Date
init(accountID: String, text: String, contentWarning: String?, inReplyToID: String?, attachments: [CompositionAttachment], 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: [CompositionAttachment]) {
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
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -31,6 +31,8 @@
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.social-networking</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>

View File

@ -18,6 +18,7 @@ class LocalData: ObservableObject {
private init() {
if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING") {
defaults = UserDefaults(suiteName: "\(Bundle.main.bundleIdentifier!).uitesting")!
defaults.removePersistentDomain(forName: "\(Bundle.main.bundleIdentifier!).uitesting")
if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING_LOGIN") {
accounts = [
UserAccountInfo(
@ -30,7 +31,20 @@ class LocalData: ObservableObject {
]
}
} else {
defaults = UserDefaults()
defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")!
tryMigrateOldDefaults()
}
}
// TODO: remove me before public beta
private func tryMigrateOldDefaults() {
let old = UserDefaults()
if let accounts = old.array(forKey: accountsKey) as? [[String: String]],
let mostRecentAccount = old.string(forKey: mostRecentAccountKey) {
defaults.setValue(accounts, forKey: accountsKey)
defaults.setValue(mostRecentAccount, forKey: mostRecentAccountKey)
old.removeObject(forKey: accountsKey)
old.removeObject(forKey: mostRecentAccountKey)
}
}

View File

@ -10,22 +10,48 @@ import Foundation
import UIKit
import MobileCoreServices
final class CompositionAttachment: NSObject, Codable {
final class CompositionAttachment: NSObject, Codable, ObservableObject {
static let typeIdentifier = "space.vaccor.Tusker.composition-attachment"
let data: CompositionAttachmentData
var attachmentDescription: String
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.data == rhs.data
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

View File

@ -1,23 +0,0 @@
//
// CompositionState.swift
// Tusker
//
// Created by Shadowfacts on 1/17/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
struct CompositionState: OptionSet {
let rawValue: Int
static let currentlyPosting = CompositionState(rawValue: 1 << 0)
static let tooManyCharacters = CompositionState(rawValue: 1 << 1)
static let requiresAttachmentDescriptions = CompositionState(rawValue: 1 << 2)
static let valid: CompositionState = []
var isValid: Bool {
isEmpty
}
}

142
Tusker/Models/Draft.swift Normal file
View File

@ -0,0 +1,142 @@
//
// 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]()
if let inReplyToID = inReplyToID,
let inReplyTo = persistentContainer.status(for: inReplyToID) {
acctsToMention.append(inReplyTo.account.acct)
acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct))
}
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
return draft
}
}

View File

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

View File

@ -38,7 +38,6 @@ class Preferences: Codable, ObservableObject {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme)
self.showRepliesInProfiles = try container.decode(Bool.self, forKey: .showRepliesInProfiles)
self.avatarStyle = try container.decode(AvatarStyle.self, forKey: .avatarStyle)
self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames)
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
@ -56,6 +55,12 @@ class Preferences: Codable, ObservableObject {
self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode)
if container.contains(.expandAllContentWarnings) {
self.expandAllContentWarnings = try container.decode(Bool.self, forKey: .expandAllContentWarnings)
}
if container.contains(.collapseLongPosts) {
self.collapseLongPosts = try container.decode(Bool.self, forKey: .collapseLongPosts)
}
self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
@ -68,7 +73,6 @@ class Preferences: Codable, ObservableObject {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(theme, forKey: .theme)
try container.encode(showRepliesInProfiles, forKey: .showRepliesInProfiles)
try container.encode(avatarStyle, forKey: .avatarStyle)
try container.encode(hideCustomEmojiInUsernames, forKey: .hideCustomEmojiInUsernames)
try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon)
@ -86,6 +90,8 @@ class Preferences: Codable, ObservableObject {
try container.encode(openLinksInApps, forKey: .openLinksInApps)
try container.encode(useInAppSafari, forKey: .useInAppSafari)
try container.encode(inAppSafariAutomaticReaderMode, forKey: .inAppSafariAutomaticReaderMode)
try container.encode(expandAllContentWarnings, forKey: .expandAllContentWarnings)
try container.encode(collapseLongPosts, forKey: .collapseLongPosts)
try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts)
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
@ -96,7 +102,6 @@ class Preferences: Codable, ObservableObject {
// MARK: Appearance
@Published var theme = UIUserInterfaceStyle.unspecified
@Published var showRepliesInProfiles = false
@Published var avatarStyle = AvatarStyle.roundRect
@Published var hideCustomEmojiInUsernames = false
@Published var showIsStatusReplyIcon = false
@ -117,6 +122,8 @@ class Preferences: Codable, ObservableObject {
@Published var openLinksInApps = true
@Published var useInAppSafari = true
@Published var inAppSafariAutomaticReaderMode = false
@Published var expandAllContentWarnings = false
@Published var collapseLongPosts = true
// MARK: Digital Wellness
@Published var showFavoriteAndReblogCounts = true
@ -128,7 +135,6 @@ class Preferences: Codable, ObservableObject {
enum CodingKeys: String, CodingKey {
case theme
case showRepliesInProfiles
case avatarStyle
case hideCustomEmojiInUsernames
case showIsStatusReplyIcon
@ -146,6 +152,8 @@ class Preferences: Codable, ObservableObject {
case openLinksInApps
case useInAppSafari
case inAppSafariAutomaticReaderMode
case expandAllContentWarnings
case collapseLongPosts
case showFavoriteAndReblogCounts
case defaultNotificationsType

View File

@ -157,8 +157,17 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
mastodonController.getOwnAccount()
mastodonController.getOwnInstance()
let tabBarController = MainTabBarViewController(mastodonController: mastodonController)
window!.rootViewController = tabBarController
let rootController: UIViewController
#if SDK_IOS_14
if #available(iOS 14.0, *) {
rootController = MainSplitViewController(mastodonController: mastodonController)
} else {
rootController = MainTabBarViewController(mastodonController: mastodonController)
}
#else
rootController = MainTabBarViewController(mastodonController: mastodonController)
#endif
window!.rootViewController = rootController
}
func showOnboardingUI() {

View File

@ -22,20 +22,22 @@ class AssetCollectionViewController: UICollectionViewController {
weak var delegate: AssetCollectionViewControllerDelegate?
var flowLayout: UICollectionViewFlowLayout {
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var flowLayout: UICollectionViewFlowLayout {
return collectionViewLayout as! UICollectionViewFlowLayout
}
var availableWidth: CGFloat!
var thumbnailSize: CGSize!
private var availableWidth: CGFloat!
private var thumbnailSize: CGSize!
let imageManager = PHCachingImageManager()
var fetchResult: PHFetchResult<PHAsset>!
private let imageManager = PHCachingImageManager()
private var fetchResult: PHFetchResult<PHAsset>!
var selectedAssets: [PHAsset] {
return collectionView.indexPathsForSelectedItems?.map({ (indexPath) in
fetchResult.object(at: indexPath.row - 1)
}) ?? []
return collectionView.indexPathsForSelectedItems?.compactMap { (indexPath) in
guard case let .asset(asset) = dataSource.itemIdentifier(for: indexPath) else { return nil }
return asset
} ?? []
}
init() {
@ -71,14 +73,46 @@ class AssetCollectionViewController: UICollectionViewController {
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)
updateItemsSelected()
updateItemsSelectedCount()
if let singleFingerPanGesture = collectionView.gestureRecognizers?.first(where: {
$0.name == "multi-select.singleFingerPanGesture"
@ -103,55 +137,16 @@ class AssetCollectionViewController: UICollectionViewController {
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
let scale = UIScreen.main.scale
let cellSize = flowLayout.itemSize
thumbnailSize = CGSize(width: cellSize.width * scale, height: cellSize.height * scale)
}
open func fetchAssets(with options: PHFetchOptions) -> PHFetchResult<PHAsset> {
return PHAsset.fetchAssets(with: options)
}
func updateItemsSelected() {
func updateItemsSelectedCount() {
let selected = collectionView.indexPathsForSelectedItems?.count ?? 0
navigationItem.title = "\(selected) selected"
}
// MARK: UICollectionViewDataSource
override func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return fetchResult.count + 1
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
if indexPath.row == 0 {
return collectionView.dequeueReusableCell(withReuseIdentifier: cameraReuseIdentifier, for: indexPath)
} else {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! AssetCollectionViewCell
let asset = fetchResult.object(at: indexPath.row - 1)
cell.updateUI(asset: asset)
imageManager.requestImage(for: asset, targetSize: 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
}
}
// MARK: UICollectionViewDelegate
override func collectionView(_ collectionView: UICollectionView, shouldBeginMultipleSelectionInteractionAt indexPath: IndexPath) -> Bool {
@ -159,37 +154,35 @@ class AssetCollectionViewController: UICollectionViewController {
}
override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
if indexPath.row > 0,
let delegate = delegate {
let asset = fetchResult.object(at: indexPath.row - 1)
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) {
if indexPath.row == 0 {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .showCamera:
collectionView.deselectItem(at: indexPath, animated: false)
delegate?.captureFromCamera()
} else {
updateItemsSelected()
case .asset(_):
updateItemsSelectedCount()
}
}
override func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
updateItemsSelected()
updateItemsSelectedCount()
}
override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
if indexPath.row == 0 {
return nil
} else {
let asset = fetchResult.object(at: indexPath.row - 1)
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?,
@ -210,3 +203,13 @@ class AssetCollectionViewController: UICollectionViewController {
}
}
extension AssetCollectionViewController {
enum Section: Hashable {
case assets
}
enum Item: Hashable {
case showCamera
case asset(PHAsset)
}
}

View File

@ -9,14 +9,14 @@
import UIKit
import Photos
protocol AssetPickerViewControllerDelegate {
protocol AssetPickerViewControllerDelegate: class {
func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool
func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachmentData])
}
class AssetPickerViewController: UINavigationController {
var assetPickerDelegate: AssetPickerViewControllerDelegate?
weak var assetPickerDelegate: AssetPickerViewControllerDelegate?
var currentCollectionSelectedAssets: [CompositionAttachmentData] {
if let vc = visibleViewController as? AssetCollectionViewController {

View File

@ -27,6 +27,10 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
}
var animationSourceView: UIImageView? { sourceViews[currentIndex] }
var largeImageController: LargeImageViewController? {
// use protocol because page controllers may be loading or non-loading VCs
(pages[currentIndex] as? LargeImageAnimatableViewController)?.largeImageController
}
var animationImage: UIImage? {
if let page = pages[currentIndex] as? LargeImageAnimatableViewController,
let image = page.animationImage {
@ -48,6 +52,9 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
override var prefersStatusBarHidden: Bool {
return true
}
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
return .none
}
override var childForHomeIndicatorAutoHidden: UIViewController? {
return viewControllers?.first
}
@ -70,6 +77,7 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
case .image:
let vc = LoadingLargeImageViewController(attachment: attachment)
vc.shrinkGestureEnabled = false
vc.animationSourceView = sourceViews[index]
return vc
case .video, .audio:
let vc = GalleryPlayerViewController()

View File

@ -149,20 +149,6 @@ class BookmarksTableViewController: EnhancedTableViewController {
return config
}
override func getSuggestedContextMenuActions(tableView: UITableView, indexPath: IndexPath, point: CGPoint) -> [UIAction] {
guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else { return [] }
return [
UIAction(title: NSLocalizedString("Unbookmark", comment: "unbookmark action title"), image: UIImage(systemName: "bookmark.fill"), identifier: .init("unbookmark"), discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in
let request = Status.unbookmark(status.id)
self.mastodonController.run(request) { (response) in
guard case let .success(newStatus, _) = response else { fatalError() }
self.mastodonController.persistentContainer.addOrUpdate(status: newStatus, incrementReferenceCount: false)
self.statuses.remove(at: indexPath.row)
}
})
]
}
}
extension BookmarksTableViewController: StatusTableViewCellDelegate {

View File

@ -0,0 +1,29 @@
//
// ComposeAssetPicker.swift
// Tusker
//
// Created by Shadowfacts on 8/19/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
struct ComposeAssetPicker: UIViewControllerRepresentable {
typealias UIViewControllerType = AssetPickerViewController
@ObservedObject var draft: Draft
let delegate: AssetPickerViewControllerDelegate?
@EnvironmentObject var mastodonController: MastodonController
func makeUIViewController(context: Context) -> AssetPickerViewController {
let vc = AssetPickerViewController()
vc.assetPickerDelegate = delegate
vc.preferredContentSize = CGSize(width: 400, height: 600)
return vc
}
func updateUIViewController(_ uiViewController: AssetPickerViewController, context: Context) {
}
}

View File

@ -0,0 +1,206 @@
//
// ComposeAttachmentRow.swift
// Tusker
//
// Created by Shadowfacts on 8/19/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
import Photos
import AVFoundation
import Vision
struct ComposeAttachmentRow: View {
@ObservedObject var draft: Draft
@ObservedObject var attachment: CompositionAttachment
let heightChanged: (CGFloat) -> Void
@EnvironmentObject var uiState: ComposeUIState
@State private var mode: Mode = .allowEntry
@State private var image: UIImage? = nil
@State private var imageContentMode: ContentMode = .fill
@State private var imageBackgroundColor: Color = .black
@State private var isShowingTextRecognitionFailedAlert = false
@State private var textRecognitionErrorMessage: String? = nil
@Environment(\.colorScheme) private var colorScheme: ColorScheme
var body: some View {
HStack(alignment: .center, spacing: 4) {
imageView
.frame(width: 80, height: 80)
.cornerRadius(8)
.contextMenu {
if case .drawing(_) = attachment.data {
Button(action: self.editDrawing) {
if #available(iOS 14.0, *) {
Label("Edit Drawing", systemImage: "hand.draw")
} else {
HStack {
Text("Edit Drawing")
Image(systemName: "hand.draw")
}
}
}
} else if attachment.data.type == .image {
Button(action: self.recognizeText) {
if #available(iOS 14.0, *) {
Label("Recognize Text", systemImage: "doc.text.viewfinder")
} else {
HStack {
Text("Recognize Text")
Image(systemName: "doc.text.viewfinder")
}
}
}
}
}
switch mode {
case .allowEntry:
ComposeTextView(text: $attachment.attachmentDescription, placeholder: Text("Describe for the visually impaired…"), minHeight: 80)
.heightDidChange(self.heightChanged)
.backgroundColor(.clear)
.fontSize(17)
case .recognizingText:
if #available(iOS 14.0, *) {
ProgressView()
} else {
ActivityIndicatorView()
}
}
// todo: find a way to make this button not activated when the list row is selected, see FB8595628
// Button(action: self.removeAttachment) {
// Image(systemName: "xmark.circle.fill")
// .foregroundColor(.blue)
// }
}
.onAppear(perform: self.loadImage)
.onReceive(attachment.$attachmentDescription) { (newDesc) in
if newDesc.isEmpty {
uiState.attachmentsMissingDescriptions.insert(attachment.id)
} else {
uiState.attachmentsMissingDescriptions.remove(attachment.id)
}
}
.alert(isPresented: $isShowingTextRecognitionFailedAlert) {
Alert(
title: Text("Text Recognition Failed"),
message: Text(self.textRecognitionErrorMessage ?? ""),
dismissButton: .default(Text("OK"))
)
}
}
@ViewBuilder
private var imageView: some View {
if let image = image {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: imageContentMode)
.background(imageBackgroundColor)
} else {
Image(systemName: placeholderImageName)
}
}
private var placeholderImageName: String {
switch colorScheme {
case .dark:
return "photo.fill"
case .light:
return "photo"
@unknown default:
return "photo"
}
}
private func loadImage() {
switch attachment.data {
case let .image(image):
self.image = image
case let .asset(asset):
let size = CGSize(width: 80, height: 80)
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in
DispatchQueue.main.async {
self.image = image
}
}
case let .video(url):
let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset)
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
self.image = UIImage(cgImage: cgImage)
}
case let .drawing(drawing):
image = drawing.imageInLightMode(from: drawing.bounds)
imageContentMode = .fit
imageBackgroundColor = .white
}
}
private func removeAttachment() {
draft.attachments.removeAll { $0.id == attachment.id }
}
private func editDrawing() {
uiState.composeDrawingMode = .edit(id: attachment.id)
uiState.delegate?.presentComposeDrawing()
}
private func recognizeText() {
mode = .recognizingText
DispatchQueue.global(qos: .userInitiated).async {
self.attachment.data.getData { (data, mimeType) in
let handler = VNImageRequestHandler(data: data, options: [:])
let request = VNRecognizeTextRequest { (request, error) in
DispatchQueue.main.async {
if let results = request.results as? [VNRecognizedTextObservation] {
var text = ""
for observation in results {
let result = observation.topCandidates(1).first!
text.append(result.string)
text.append("\n")
}
self.attachment.attachmentDescription = text
}
self.mode = .allowEntry
}
}
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
DispatchQueue.global(qos: .userInitiated).async {
do {
try handler.perform([request])
} catch {
// The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for.
guard (error as NSError).code != 1 else { return }
DispatchQueue.main.async {
self.mode = .allowEntry
self.isShowingTextRecognitionFailedAlert = true
self.textRecognitionErrorMessage = error.localizedDescription
}
}
}
}
}
}
}
extension ComposeAttachmentRow {
enum Mode {
case allowEntry, recognizingText
}
}
//struct ComposeAttachmentRow_Previews: PreviewProvider {
// static var previews: some View {
// ComposeAttachmentRow()
// }
//}

View File

@ -0,0 +1,174 @@
//
// ComposeAttachmentsList.swift
// Tusker
//
// Created by Shadowfacts on 8/19/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
struct ComposeAttachmentsList: View {
private let cellHeight: CGFloat = 80
private let cellPadding: CGFloat = 12
@ObservedObject var draft: Draft
@EnvironmentObject var mastodonController: MastodonController
@EnvironmentObject var uiState: ComposeUIState
@State var isShowingAssetPickerPopover = false
@State var isShowingCreateDrawing = false
@State var rowHeights = [UUID: CGFloat]()
@Environment(\.colorScheme) var colorScheme: ColorScheme
@Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass?
var body: some View {
List {
ForEach(draft.attachments) { (attachment) in
ComposeAttachmentRow(
draft: draft,
attachment: attachment
) { (newHeight) in
// in case height changed callback is called after atachment is removed but before view hierarchy is updated
if draft.attachments.contains(where: { $0.id == attachment.id }) {
rowHeights[attachment.id] = newHeight
}
}
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
}
.onMove(perform: self.moveAttachments)
.onDelete(perform: self.deleteAttachments)
.conditionally(canAddAttachment) {
$0.onInsert(of: CompositionAttachment.readableTypeIdentifiersForItemProvider, perform: self.insertAttachments)
}
Button(action: self.addAttachment) {
if #available(iOS 14.0, *) {
Label("Add photo or video", systemImage: addButtonImageName)
} else {
HStack {
Image(systemName: addButtonImageName)
Text("Add photo or video")
}
}
}
.disabled(!canAddAttachment)
.foregroundColor(.blue)
.frame(height: cellHeight / 2)
.popover(isPresented: $isShowingAssetPickerPopover, content: self.assetPickerPopover)
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
Button(action: self.createDrawing) {
if #available(iOS 14.0, *) {
Label("Draw something", systemImage: "hand.draw")
} else {
HStack(alignment: .lastTextBaseline) {
Image(systemName: "hand.draw")
Text("Draw something")
}
}
}
.disabled(!canAddAttachment)
.foregroundColor(.blue)
.frame(height: cellHeight / 2)
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
}
.frame(height: totalListHeight)
.onAppear(perform: self.didAppear)
.onReceive(draft.$attachments, perform: self.attachmentsChanged)
}
private var addButtonImageName: String {
switch colorScheme {
case .dark:
return "photo.fill"
case .light:
return "photo"
@unknown default:
return "photo"
}
}
private var canAddAttachment: Bool {
switch mastodonController.instance.instanceType {
case .pleroma:
return true
case .mastodon:
return draft.attachments.count < 4 && draft.attachments.allSatisfy { $0.data.type == .image }
}
}
private var totalListHeight: CGFloat {
let totalRowHeights = rowHeights.values.reduce(0, +)
let totalPadding = CGFloat(draft.attachments.count) * cellPadding
let addButtonHeight = cellHeight + cellPadding * 2
return totalRowHeights + totalPadding + addButtonHeight
}
private func didAppear() {
let proxy = UITableView.appearance(whenContainedInInstancesOf: [ComposeHostingController.self])
// enable drag and drop to reorder on iPhone
proxy.dragInteractionEnabled = true
}
private func attachmentsChanged(attachments: [CompositionAttachment]) {
var copy = rowHeights
for k in copy.keys where !attachments.contains(where: { k == $0.id }) {
copy.removeValue(forKey: k)
}
for attachment in attachments where !copy.keys.contains(attachment.id) {
copy[attachment.id] = cellHeight
}
self.rowHeights = copy
}
private func assetPickerPopover() -> some View {
ComposeAssetPicker(draft: draft, delegate: uiState.delegate?.assetPickerDelegate)
.onDisappear {
self.isShowingAssetPickerPopover = false
}
.environment(\.colorScheme, .dark)
.edgesIgnoringSafeArea(.bottom)
}
private func addAttachment() {
if horizontalSizeClass == .regular {
isShowingAssetPickerPopover = true
} else {
uiState.delegate?.presentAssetPickerSheet()
}
}
private func moveAttachments(from source: IndexSet, to destination: Int) {
draft.attachments.move(fromOffsets: source, toOffset: destination)
}
private func deleteAttachments(at indices: IndexSet) {
draft.attachments.remove(atOffsets: indices)
}
private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) {
for provider in itemProviders where provider.canLoadObject(ofClass: CompositionAttachment.self) {
guard canAddAttachment else { break }
provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in
guard let attachment = object as? CompositionAttachment else { return }
DispatchQueue.main.async {
self.draft.attachments.insert(attachment, at: offset)
}
}
}
}
private func createDrawing() {
uiState.composeDrawingMode = .createNew
uiState.delegate?.presentComposeDrawing()
}
}
//struct ComposeAttachmentsList_Previews: PreviewProvider {
// static var previews: some View {
// ComposeAttachmentsList()
// }
//}

View File

@ -1,563 +0,0 @@
//
// ComposeAttachmentsViewController.swift
// Tusker
//
// Created by Shadowfacts on 3/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import MobileCoreServices
import PencilKit
import Photos
protocol ComposeAttachmentsViewControllerDelegate: class {
func composeSelectedAttachmentsDidChange()
func composeRequiresAttachmentDescriptionsDidChange()
}
class ComposeAttachmentsViewController: UITableViewController {
weak var mastodonController: MastodonController!
weak var delegate: ComposeAttachmentsViewControllerDelegate?
private var heightConstraint: NSLayoutConstraint!
var attachments: [CompositionAttachment] = [] {
didSet {
delegate?.composeSelectedAttachmentsDidChange()
delegate?.composeRequiresAttachmentDescriptionsDidChange()
updateAddAttachmentsButtonEnabled()
}
}
var requiresAttachmentDescriptions: Bool {
if Preferences.shared.requireAttachmentDescriptions {
return attachments.contains { $0.attachmentDescription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
} else {
return false
}
}
private var currentlyEditedDrawingIndex: Int?
init(attachments: [CompositionAttachment], mastodonController: MastodonController) {
self.attachments = attachments
self.mastodonController = mastodonController
super.init(style: .plain)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 96
tableView.register(UINib(nibName: "AddAttachmentTableViewCell", bundle: .main), forCellReuseIdentifier: "addAttachment")
tableView.register(UINib(nibName: "ComposeAttachmentTableViewCell", bundle: .main), forCellReuseIdentifier: "composeAttachment")
// you would think the table view could handle this itself, but no, using a constraint on the table view's contentLayoutGuide doesn't work
// add extra space, so when dropping items, the add attachment cell doesn't disappear
heightConstraint = tableView.heightAnchor.constraint(equalToConstant: tableView.contentSize.height + 80)
heightConstraint.isActive = true
// prevents extra separator lines from appearing when the height of the table view is greater than the height of the content
tableView.tableFooterView = UIView()
pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self)
// enable dragging on iPhone to allow reordering
tableView.dragInteractionEnabled = true
tableView.dragDelegate = self
tableView.dropDelegate = self
if mastodonController.instance == nil {
mastodonController.getOwnInstance { [weak self] (_) in
guard let self = self else { return }
DispatchQueue.main.async {
self.updateAddAttachmentsButtonEnabled()
}
}
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
updateHeightConstraint()
}
func setAttachments(_ attachments: [CompositionAttachment]) {
tableView.performBatchUpdates({
tableView.deleteRows(at: self.attachments.indices.map { IndexPath(row: $0, section: 0) }, with: .automatic)
self.attachments = attachments
tableView.insertRows(at: self.attachments.indices.map { IndexPath(row: $0, section: 0) }, with: .automatic)
})
updateHeightConstraint()
delegate?.composeRequiresAttachmentDescriptionsDidChange()
}
private func updateHeightConstraint() {
// add extra space, so when dropping items, the add attachment cell doesn't disappear
heightConstraint.constant = tableView.contentSize.height + 80
}
private func isAddAttachmentsButtonEnabled() -> Bool {
switch mastodonController.instance?.instanceType {
case nil:
return false
case .pleroma:
return true
case .mastodon:
return !attachments.contains(where: { $0.data.type == .video }) && attachments.count < 4
}
}
private func updateAddAttachmentsButtonEnabled() {
guard let cell = tableView.cellForRow(at: IndexPath(row: 0, section: 1)) as? AddAttachmentTableViewCell else { return }
cell.setEnabled(isAddAttachmentsButtonEnabled())
}
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else {
return false
}
switch mastodonController.instance.instanceType {
case .pleroma:
return true
case .mastodon:
return itemProviders.count + attachments.count <= 4
}
}
override func paste(itemProviders: [NSItemProvider]) {
for provider in itemProviders {
provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in
if let error = error {
fatalError("Couldn't load image from NSItemProvider: \(error)")
}
guard let attachment = object as? CompositionAttachment else {
fatalError("Couldn't convert object from NSItemProvider to CompositionAttachment")
}
DispatchQueue.main.async {
self.attachments.append(attachment)
self.tableView.insertRows(at: [IndexPath(row: self.attachments.count - 1, section: 0)], with: .automatic)
self.updateHeightConstraint()
}
}
}
}
func presentComposeDrawingViewController(editingAttachmentAt attachmentIndex: Int? = nil) {
let drawingVC: ComposeDrawingViewController
if let index = attachmentIndex,
case let .drawing(drawing) = attachments[index].data {
drawingVC = ComposeDrawingViewController(editing: drawing)
currentlyEditedDrawingIndex = index
} else {
drawingVC = ComposeDrawingViewController()
}
drawingVC.delegate = self
let nav = UINavigationController(rootViewController: drawingVC)
nav.modalPresentationStyle = .fullScreen
present(nav, animated: true)
}
func uploadAll(stepProgress: @escaping () -> Void, completion: @escaping (_ success: Bool, _ uploadedAttachments: [Attachment]) -> Void) {
let group = DispatchGroup()
var anyFailed = false
var uploadedAttachments: [Result<Attachment, Error>?] = []
for (index, compAttachment) in attachments.enumerated() {
group.enter()
uploadedAttachments.append(nil)
compAttachment.data.getData { (data, mimeType) in
stepProgress()
let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: "file")
let request = Client.upload(attachment: formAttachment, description: compAttachment.attachmentDescription)
self.mastodonController.run(request) { (response) in
switch response {
case let .failure(error):
uploadedAttachments[index] = .failure(error)
anyFailed = true
case let .success(attachment, _):
uploadedAttachments[index] = .success(attachment)
}
stepProgress()
group.leave()
}
}
}
group.notify(queue: .main) {
if anyFailed {
let errors: [(Int, Error)] = uploadedAttachments.enumerated().compactMap { (index, result) in
switch result {
case let .failure(error):
return (index, error)
default:
return nil
}
}
let title: String
var message: String
if errors.count == 1 {
title = NSLocalizedString("Could not upload attachment", comment: "single attachment upload failed alert title")
message = errors[0].1.localizedDescription
} else {
title = NSLocalizedString("Could not upload the following attachments", comment: "multiple attachment upload failures alert title")
message = ""
for (index, error) in errors {
message.append("Attachment \(index + 1): \(error.localizedDescription)")
}
}
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: { (_) in
completion(false, [])
}))
} else {
let uploadedAttachments: [Attachment] = uploadedAttachments.compactMap {
switch $0 {
case let .success(attachment):
return attachment
default:
return nil
}
}
completion(true, uploadedAttachments)
}
}
}
// MARK: Table view data source
override func numberOfSections(in tableView: UITableView) -> Int {
return 2
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
switch section {
case 0:
return attachments.count
case 1:
return 1
default:
fatalError("invalid section \(section)")
}
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
switch indexPath.section {
case 0:
let attachment = attachments[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: "composeAttachment", for: indexPath) as! ComposeAttachmentTableViewCell
cell.delegate = self
cell.updateUI(for: attachment)
cell.setEnabled(true)
return cell
case 1:
let cell = tableView.dequeueReusableCell(withIdentifier: "addAttachment", for: indexPath) as! AddAttachmentTableViewCell
cell.setEnabled(isAddAttachmentsButtonEnabled())
return cell
default:
fatalError("invalid section \(indexPath.section)")
}
}
override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
guard sourceIndexPath != destinationIndexPath, sourceIndexPath.section == 0, destinationIndexPath.section == 0 else { return }
attachments.insert(attachments.remove(at: sourceIndexPath.row), at: destinationIndexPath.row)
}
// MARK: Table view delegate
override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
if indexPath.section == 1, isAddAttachmentsButtonEnabled() {
return indexPath
}
return nil
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
if indexPath.section == 1 {
addAttachmentPressed()
}
}
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
if indexPath.section == 0 {
let attachment = attachments[indexPath.row]
// cast to NSIndexPath because identifier needs to conform to NSCopying
return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: { () -> UIViewController? in
return AssetPreviewViewController(attachment: attachment.data)
}) { (_) -> UIMenu? in
var actions = [UIAction]()
switch attachment.data {
case .drawing(_):
actions.append(UIAction(title: "Edit Drawing", image: UIImage(systemName: "hand.draw"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in
self.presentComposeDrawingViewController(editingAttachmentAt: indexPath.row)
}))
case .asset(_), .image(_):
if attachment.data.type == .image,
let cell = tableView.cellForRow(at: indexPath) as? ComposeAttachmentTableViewCell {
let title = NSLocalizedString("Recognize Text", comment: "recognize image attachment text menu item title")
actions.append(UIAction(title: title, image: UIImage(systemName: "doc.text.viewfinder"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in
cell.recognizeTextFromImage()
}))
}
default:
break
}
if actions.isEmpty {
return nil
} else {
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
}
}
} else if indexPath.section == 1 {
guard isAddAttachmentsButtonEnabled() else {
return nil
}
// show context menu for drawing/file uploads
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) -> UIMenu? in
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: [
UIAction(title: "Draw Something", image: UIImage(systemName: "hand.draw"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in
self.presentComposeDrawingViewController()
})
])
}
} else {
return nil
}
}
private func targetedPreview(forConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
if let indexPath = (configuration.identifier as? NSIndexPath) as IndexPath?,
indexPath.section == 0,
let cell = tableView.cellForRow(at: indexPath) as? ComposeAttachmentTableViewCell {
let parameters = UIPreviewParameters()
parameters.backgroundColor = .black
return UITargetedPreview(view: cell.assetImageView, parameters: parameters)
} else {
return nil
}
}
override func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
return targetedPreview(forConfiguration: configuration)
}
override func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
return targetedPreview(forConfiguration: configuration)
}
// MARK: Interaction
func addAttachmentPressed() {
PHPhotoLibrary.requestAuthorization { (status) in
guard status == .authorized else { return }
DispatchQueue.main.async {
if self.traitCollection.horizontalSizeClass == .compact {
let sheetContainer = AssetPickerSheetContainerViewController()
sheetContainer.assetPicker.assetPickerDelegate = self
self.present(sheetContainer, animated: true)
} else {
let picker = AssetPickerViewController()
picker.assetPickerDelegate = self
picker.overrideUserInterfaceStyle = .dark
picker.modalPresentationStyle = .popover
self.present(picker, animated: true)
if let presentationController = picker.presentationController as? UIPopoverPresentationController {
presentationController.sourceView = self.tableView.cellForRow(at: IndexPath(row: 0, section: 1))
}
}
}
}
}
}
extension ComposeAttachmentsViewController: UITableViewDragDelegate {
func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard indexPath.section == 0 else { return [] }
let attachment = attachments[indexPath.row]
let provider = NSItemProvider(object: attachment)
let dragItem = UIDragItem(itemProvider: provider)
dragItem.localObject = attachment
return [dragItem]
}
func tableView(_ tableView: UITableView, itemsForAddingTo session: UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem] {
guard indexPath.section == 0 else { return [] }
let attachment = attachments[indexPath.row]
let provider = NSItemProvider(object: attachment)
let dragItem = UIDragItem(itemProvider: provider)
dragItem.localObject = attachment
return [dragItem]
}
func tableView(_ tableView: UITableView, dragPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters? {
guard indexPath.section == 0 else { return nil }
let cell = tableView.cellForRow(at: indexPath) as! ComposeAttachmentTableViewCell
let rect = cell.convert(cell.assetImageView.bounds, from: cell.assetImageView)
let path = UIBezierPath(roundedRect: rect, cornerRadius: cell.assetImageView.layer.cornerRadius)
let params = UIDragPreviewParameters()
params.visiblePath = path
return params
}
}
extension ComposeAttachmentsViewController: UITableViewDropDelegate {
func tableView(_ tableView: UITableView, canHandle session: UIDropSession) -> Bool {
return session.canLoadObjects(ofClass: CompositionAttachment.self)
}
func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
// if items were dragged out of ourself, then the items are only being moved
if tableView.hasActiveDrag {
// todo: should moving multiple items actually be prohibited?
if session.items.count > 1 {
return UITableViewDropProposal(operation: .cancel)
} else {
return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
}
} else {
return UITableViewDropProposal(operation: .copy, intent: .insertAtDestinationIndexPath)
}
}
func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
let destinationIndexPath = coordinator.destinationIndexPath ?? IndexPath(row: attachments.count, section: 0)
// we don't need to handle local items here, when the .move operation is used returned from the tableView(_:dropSessionDidUpdate:withDestinationIndexPath:) method,
// the table view will handle animating and call the normal data source tableView(_:moveRowAt:to:)
for (index, item) in coordinator.items.enumerated() {
let provider = item.dragItem.itemProvider
if provider.canLoadObject(ofClass: CompositionAttachment.self) {
let indexPath = IndexPath(row: destinationIndexPath.row + index, section: 0)
let placeholder = UITableViewDropPlaceholder(insertionIndexPath: indexPath, reuseIdentifier: "composeAttachment", rowHeight: 96)
placeholder.cellUpdateHandler = { (cell) in
let cell = cell as! ComposeAttachmentTableViewCell
cell.setEnabled(false)
}
let placeholderContext = coordinator.drop(item.dragItem, to: placeholder)
provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in
DispatchQueue.main.async {
if let attachment = object as? CompositionAttachment {
placeholderContext.commitInsertion { (insertionIndexPath) in
self.attachments.insert(attachment, at: insertionIndexPath.row)
}
} else {
placeholderContext.deletePlaceholder()
}
}
}
}
}
updateHeightConstraint()
}
}
extension ComposeAttachmentsViewController: AssetPickerViewControllerDelegate {
func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool {
switch mastodonController.instance.instanceType {
case .pleroma:
return true
case .mastodon:
if (type == .video && attachments.count > 0) ||
attachments.contains(where: { $0.data.type == .video }) ||
assetPicker.currentCollectionSelectedAssets.contains(where: { $0.type == .video }) {
return false
}
return attachments.count + assetPicker.currentCollectionSelectedAssets.count < 4
}
}
func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachmentData]) {
let attachments = attachments.map {
CompositionAttachment(data: $0)
}
let indexPaths = attachments.indices.map { IndexPath(row: $0 + self.attachments.count, section: 0) }
self.attachments.append(contentsOf: attachments)
tableView.insertRows(at: indexPaths, with: .automatic)
updateHeightConstraint()
}
}
extension ComposeAttachmentsViewController: ComposeAttachmentTableViewCellDelegate {
func composeAttachment(_ cell: ComposeAttachmentTableViewCell, present viewController: UIViewController, animated: Bool) {
self.present(viewController, animated: animated)
}
func removeAttachment(_ cell: ComposeAttachmentTableViewCell) {
guard let indexPath = tableView.indexPath(for: cell) else { return }
attachments.remove(at: indexPath.row)
tableView.performBatchUpdates({
tableView.deleteRows(at: [indexPath], with: .automatic)
}, completion: { (_) in
// when removing cells, we don't trigger the container height update until after the animation has completed
// otherwise, during the animation, the height is too short and the last row briefly disappears
self.updateHeightConstraint()
})
}
func attachmentDescriptionChanged(_ cell: ComposeAttachmentTableViewCell) {
delegate?.composeRequiresAttachmentDescriptionsDidChange()
}
func composeAttachmentDescriptionHeightChanged(_ cell: ComposeAttachmentTableViewCell) {
tableView.performBatchUpdates(nil) { (_) in
self.updateHeightConstraint()
}
}
}
extension ComposeAttachmentsViewController: ComposeDrawingViewControllerDelegate {
func composeDrawingViewControllerClose(_ drawingController: ComposeDrawingViewController) {
dismiss(animated: true)
currentlyEditedDrawingIndex = nil
}
func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing) {
let newAttachment = CompositionAttachment(data: .drawing(drawing))
if let currentlyEditedDrawingIndex = currentlyEditedDrawingIndex {
attachments[currentlyEditedDrawingIndex] = newAttachment
tableView.reloadRows(at: [IndexPath(row: currentlyEditedDrawingIndex, section: 0)], with: .automatic)
} else {
attachments.append(newAttachment)
tableView.insertRows(at: [IndexPath(row: self.attachments.count - 1, section: 0)], with: .automatic)
updateHeightConstraint()
}
dismiss(animated: true)
currentlyEditedDrawingIndex = nil
}
}

View File

@ -0,0 +1,61 @@
//
// ComposeAvatarImageView.swift
// Tusker
//
// Created by Shadowfacts on 8/18/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
struct ComposeAvatarImageView: View {
let url: URL
@State var request: ImageCache.Request? = nil
@State var avatarImage: UIImage? = nil
@ObservedObject var preferences = Preferences.shared
var body: some View {
image
.resizable()
.frame(width: 50, height: 50)
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
.onAppear(perform: self.loadImage)
.onDisappear(perform: self.cancelRequest)
}
private var image: Image {
if let avatarImage = avatarImage {
return Image(uiImage: avatarImage)
} else {
let imageName: String
switch preferences.avatarStyle {
case .circle:
imageName = "person.crop.circle"
case .roundRect:
imageName = "person.crop.square"
}
return Image(systemName: imageName)
}
}
private func loadImage() {
request = ImageCache.avatars.get(url) { (data) in
DispatchQueue.main.async {
self.request = nil
if let data = data, let image = UIImage(data: data) {
self.avatarImage = image
}
}
}
}
private func cancelRequest() {
request?.cancel()
}
}
struct ComposeAvatarImageView_Previews: PreviewProvider {
static var previews: some View {
ComposeAvatarImageView(url: URL(string: "https://social.shadowfacts.net/media/4b481afc591a8f3d11d0f5732e5cb320422dec72d7f223ebb5f35d5d0e821a9c.png")!)
}
}

View File

@ -0,0 +1,35 @@
//
// ComposeContainerView.swift
// Tusker
//
// Created by Shadowfacts on 8/24/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
import Combine
struct ComposeContainerView: View {
let mastodonController: MastodonController
@ObservedObject var uiState: ComposeUIState
init(
mastodonController: MastodonController,
uiState: ComposeUIState
) {
self.mastodonController = mastodonController
self.uiState = uiState
}
var body: some View {
ComposeView(draft: uiState.draft)
.environmentObject(mastodonController)
.environmentObject(uiState)
}
}
//struct ComposeContainerView_Previews: PreviewProvider {
// static var previews: some View {
// ComposeContainerView()
// }
//}

View File

@ -0,0 +1,41 @@
//
// ComposeCurrentAccount.swift
// Tusker
//
// Created by Shadowfacts on 8/18/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
import Pachyderm
struct ComposeCurrentAccount: View {
@EnvironmentObject var mastodonController: MastodonController
var account: Account {
mastodonController.account!
}
var body: some View {
HStack(alignment: .top) {
ComposeAvatarImageView(url: account.avatar)
.accessibility(label: Text("\(account.displayName) avatar"))
VStack(alignment: .leading) {
AccountDisplayNameLabel(account: mastodonController.persistentContainer.account(for: account.id)!, fontSize: 20)
.lineLimit(1)
Text(verbatim: "@\(account.acct)")
.font(.system(size: 17, weight: .light))
.foregroundColor(.secondary)
.lineLimit(1)
}
}
}
}
//struct ComposeCurrentAccount_Previews: PreviewProvider {
// static var previews: some View {
// ComposeCurrentAccount(account: )
// }
//}

View File

@ -58,7 +58,15 @@ class ComposeDrawingViewController: UIViewController {
canvasView.drawing = initialDrawing
}
canvasView.delegate = self
#if SDK_IOS_14
if #available(iOS 14.0, *) {
canvasView.drawingPolicy = .anyInput
} else {
canvasView.allowsFingerDrawing = true
}
#else
canvasView.allowsFingerDrawing = true
#endif
canvasView.minimumZoomScale = 0.5
canvasView.maximumZoomScale = 2
canvasView.backgroundColor = .systemBackground
@ -75,6 +83,7 @@ class ComposeDrawingViewController: UIViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// todo: should the PKToolPicker be owned by this VC or something else?
if let window = parent?.view.window, let toolPicker = PKToolPicker.shared(for: window) {
toolPicker.setVisible(true, forFirstResponder: canvasView)
toolPicker.addObserver(canvasView)
@ -166,9 +175,3 @@ extension ComposeDrawingViewController: PKToolPickerObserver {
updateLayout(for: toolPicker)
}
}
extension ComposeDrawingViewController: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return otherGestureRecognizer == canvasView.drawingGestureRecognizer
}
}

View File

@ -0,0 +1,408 @@
//
// ComposeHostingController.swift
// Tusker
//
// Created by Shadowfacts on 8/22/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
import Combine
import Pachyderm
import PencilKit
class ComposeHostingController: UIHostingController<ComposeContainerView> {
let mastodonController: MastodonController
let uiState: ComposeUIState
var draft: Draft { uiState.draft }
private var cancellables = [AnyCancellable]()
private var keyboardHeight: CGFloat = 0
private var toolbarHeight: CGFloat = 44
private var mainToolbar: UIToolbar!
private var inputAccessoryToolbar: UIToolbar!
private var visibilityBarButtonItems = [UIBarButtonItem]()
override var inputAccessoryView: UIView? { inputAccessoryToolbar }
init(draft: Draft? = nil, mastodonController: MastodonController) {
self.mastodonController = mastodonController
let realDraft = draft ?? Draft(accountID: mastodonController.accountInfo!.id)
DraftsManager.shared.add(realDraft)
self.uiState = ComposeUIState(draft: realDraft)
// we need our own environment object wrapper so that we can set the mastodon controller as an
// environment object and setup the draft change listener while still having a concrete type
// to use as the UIHostingController type parameter
let container = ComposeContainerView(
mastodonController: mastodonController,
uiState: uiState
)
super.init(rootView: container)
self.uiState.delegate = self
// main toolbar is shown at the bottom of the screen, the input accessory is attached to the keyboard while editing
mainToolbar = createToolbar()
inputAccessoryToolbar = createToolbar()
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil)
// add the height of the toolbar itself to the bottom of the safe area so content inside SwiftUI ScrollView doesn't underflow it
updateAdditionalSafeAreaInsets()
pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self)
userActivity = UserActivityManager.newPostActivity()
self.uiState.$draft
.flatMap(\.$visibility)
.sink(receiveValue: self.visibilityChanged)
.store(in: &cancellables)
self.uiState.$draft
.flatMap(\.objectWillChange)
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility))
.sink {
DraftsManager.save()
}
.store(in: &cancellables)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// can't do this in viewDidLoad because viewDidLoad isn't called for UIHostingController
// if mainToolbar.superview == nil {
// view.addSubview(mainToolbar)
// NSLayoutConstraint.activate([
// mainToolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
// mainToolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
// // use the top anchor of the toolbar so our additionalSafeAreaInsets (which has the bottom as the toolbar height) don't affect it
// mainToolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
// ])
// }
}
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
if let parent = parent {
parent.view.addSubview(mainToolbar)
NSLayoutConstraint.activate([
mainToolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
mainToolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
// use the top anchor of the toolbar so our additionalSafeAreaInsets (which has the bottom as the toolbar height) don't affect it
mainToolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
])
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if !draft.hasContent {
DraftsManager.shared.remove(draft)
}
DraftsManager.save()
}
private func createToolbar() -> UIToolbar {
let toolbar = UIToolbar()
toolbar.translatesAutoresizingMaskIntoConstraints = false
toolbar.isAccessibilityElement = true
let visibilityAction: Selector?
if #available(iOS 14.0, *) {
visibilityAction = nil
} else {
visibilityAction = #selector(visibilityButtonPressed(_:))
}
let visibilityItem = UIBarButtonItem(image: UIImage(systemName: draft.visibility.imageName), style: .plain, target: self, action: visibilityAction)
visibilityBarButtonItems.append(visibilityItem)
visibilityChanged(draft.visibility)
toolbar.items = [
UIBarButtonItem(title: "CW", style: .plain, target: self, action: #selector(cwButtonPressed)),
visibilityItem,
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
UIBarButtonItem(title: "Drafts", style: .plain, target: self, action: #selector(draftsButtonPresed))
]
return toolbar
}
private func updateAdditionalSafeAreaInsets() {
additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: toolbarHeight + keyboardHeight, right: 0)
}
@objc private func keyboardWillShow(_ notification: Foundation.Notification) {
keyboardWillShow(accessoryView: inputAccessoryToolbar, notification: notification)
}
func keyboardWillShow(accessoryView: UIView, notification: Foundation.Notification) {
mainToolbar.isHidden = true
accessoryView.alpha = 1
accessoryView.isHidden = false
// on iOS 14, SwiftUI safe area automatically includes the keyboard
if #available(iOS 14.0, *) {
} else {
let userInfo = notification.userInfo!
let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
// temporarily reset add'l safe area insets so we can access the default inset
additionalSafeAreaInsets = .zero
keyboardHeight = frame.height - view.safeAreaInsets.bottom - accessoryView.frame.height
updateAdditionalSafeAreaInsets()
}
}
@objc private func keyboardWillHide(_ notification: Foundation.Notification) {
keyboardWillHide(accessoryView: inputAccessoryToolbar, notification: notification)
}
func keyboardWillHide(accessoryView: UIView, notification: Foundation.Notification) {
mainToolbar.isHidden = false
let userInfo = notification.userInfo!
let durationObj = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as! NSNumber
let duration = TimeInterval(durationObj.doubleValue)
let curveValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as! NSNumber
let curve = UIView.AnimationCurve(rawValue: curveValue.intValue)!
let curveOption: UIView.AnimationOptions
switch curve {
case .easeInOut:
curveOption = .curveEaseInOut
case .easeIn:
curveOption = .curveEaseIn
case .easeOut:
curveOption = .curveEaseOut
case .linear:
curveOption = .curveLinear
@unknown default:
curveOption = .curveLinear
}
UIView.animate(withDuration: duration, delay: 0, options: curveOption) {
accessoryView.alpha = 0
} completion: { (finished) in
accessoryView.alpha = 1
}
// on iOS 14, SwiftUI safe area automatically includes the keyboard
if #available(iOS 14.0, *) {
} else {
keyboardHeight = 0
updateAdditionalSafeAreaInsets()
}
}
@objc private func keyboardDidHide(_ notification: Foundation.Notification) {
keyboardDidHide(accessoryView: inputAccessoryToolbar, notification: notification)
}
func keyboardDidHide(accessoryView: UIView, notification: Foundation.Notification) {
accessoryView.isHidden = true
}
private func visibilityChanged(_ newVisibility: Status.Visibility) {
for item in visibilityBarButtonItems {
item.image = UIImage(systemName: newVisibility.imageName)
item.image!.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName)
item.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName)
if #available(iOS 14.0, *) {
let elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in
let state = visibility == newVisibility ? UIMenuElement.State.on : .off
return UIAction(title: visibility.displayName, image: UIImage(systemName: visibility.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: state) { (_) in
self.draft.visibility = visibility
}
}
item.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: elements)
}
}
}
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { return false }
switch mastodonController.instance.instanceType {
case .pleroma:
return true
case .mastodon:
guard draft.attachments.allSatisfy({ $0.data.type == .image }) else { return false }
// todo: if providers are videos, this technically allows invalid video/image combinations
return itemProviders.count + draft.attachments.count <= 4
}
}
override func paste(itemProviders: [NSItemProvider]) {
for provider in itemProviders where provider.canLoadObject(ofClass: CompositionAttachment.self) {
provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in
guard let attachment = object as? CompositionAttachment else { return }
DispatchQueue.main.async {
self.draft.attachments.append(attachment)
}
}
}
}
// MARK: Interaction
@objc func cwButtonPressed() {
draft.contentWarningEnabled = !draft.contentWarningEnabled
}
@objc func visibilityButtonPressed(_ sender: UIBarButtonItem) {
// if #available(iOS 14.0, *) {
// } else {
let alertController = UIAlertController(currentVisibility: draft.visibility) { (visibility) in
guard let visibility = visibility else { return }
self.draft.visibility = visibility
}
alertController.popoverPresentationController?.barButtonItem = sender
present(alertController, animated: true)
// }
}
@objc func draftsButtonPresed() {
let draftsVC = DraftsTableViewController(account: mastodonController.accountInfo!, exclude: draft)
draftsVC.delegate = self
present(UINavigationController(rootViewController: draftsVC), animated: true)
}
}
extension ComposeHostingController: ComposeUIStateDelegate {
var assetPickerDelegate: AssetPickerViewControllerDelegate? { self }
func dismissCompose() {
self.dismiss(animated: true)
}
func presentAssetPickerSheet() {
let sheetContainer = AssetPickerSheetContainerViewController()
sheetContainer.assetPicker.assetPickerDelegate = self
self.present(sheetContainer, animated: true)
}
func presentComposeDrawing() {
let drawing: PKDrawing
if case let .edit(id) = uiState.composeDrawingMode,
let attachment = draft.attachments.first(where: { $0.id == id }),
case let .drawing(existingDrawing) = attachment.data {
drawing = existingDrawing
} else {
drawing = PKDrawing()
}
let drawingVC = ComposeDrawingViewController(editing: drawing)
drawingVC.delegate = self
let nav = UINavigationController(rootViewController: drawingVC)
nav.modalPresentationStyle = .fullScreen
present(nav, animated: true)
}
}
extension ComposeHostingController: AssetPickerViewControllerDelegate {
func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool {
switch mastodonController.instance.instanceType {
case .pleroma:
return true
case .mastodon:
if (type == .video && draft.attachments.count > 0) ||
draft.attachments.contains(where: { $0.data.type == .video }) ||
assetPicker.currentCollectionSelectedAssets.contains(where: { $0.type == .video }) {
return false
}
return draft.attachments.count + assetPicker.currentCollectionSelectedAssets.count < 4
}
}
func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachmentData]) {
let attachments = attachments.map {
CompositionAttachment(data: $0)
}
draft.attachments.append(contentsOf: attachments)
}
}
extension ComposeHostingController: DraftsTableViewControllerDelegate {
func draftSelectionCanceled() {
}
func shouldSelectDraft(_ draft: Draft, completion: @escaping (Bool) -> Void) {
if draft.inReplyToID != self.draft.inReplyToID,
self.draft.hasContent {
let alertController = UIAlertController(title: "Different Reply", message: "The selected draft is a reply to a different status, do you wish to use it?", preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { (_) in
completion(false)
}))
alertController.addAction(UIAlertAction(title: "Restore Draft", style: .default, handler: { (_) in
completion(true)
}))
// we can't present the laert ourselves since the compose VC is already presenting the draft selector
// but presenting on the presented view controller seems hacky, is there a better way to do this?
presentedViewController!.present(alertController, animated: true)
} else {
completion(true)
}
}
func draftSelected(_ draft: Draft) {
if self.draft.hasContent {
DraftsManager.save()
} else {
DraftsManager.shared.remove(self.draft)
}
uiState.draft = draft
}
func draftSelectionCompleted() {
}
}
extension ComposeHostingController: UIAdaptivePresentationControllerDelegate {
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
return Preferences.shared.automaticallySaveDrafts || !draft.hasContent
}
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
uiState.isShowingSaveDraftSheet = true
}
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
DraftsManager.save()
}
}
extension ComposeHostingController: ComposeDrawingViewControllerDelegate {
func composeDrawingViewControllerClose(_ drawingController: ComposeDrawingViewController) {
dismiss(animated: true)
}
func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing) {
switch uiState.composeDrawingMode {
case nil, .createNew:
let attachment = CompositionAttachment(data: .drawing(drawing))
draft.attachments.append(attachment)
case let .edit(id):
let existing = draft.attachments.first { $0.id == id }
existing?.data = .drawing(drawing)
}
dismiss(animated: true)
}
}

View File

@ -0,0 +1,14 @@
//
// ComposeHostingController.swift
// Tusker
//
// Created by Shadowfacts on 8/18/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
import UIKit
class ComposeHostingController: UIHostingController<ComposeView> {
}

View File

@ -0,0 +1,44 @@
//
// ComposeReplyContentView.swift
// Tusker
//
// Created by Shadowfacts on 8/22/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
struct ComposeReplyContentView: UIViewRepresentable {
typealias UIViewType = ComposeReplyContentTextView
let status: StatusMO
@EnvironmentObject var mastodonController: MastodonController
let heightChanged: (CGFloat) -> Void
func makeUIView(context: Context) -> ComposeReplyContentTextView {
let view = ComposeReplyContentTextView()
view.overrideMastodonController = mastodonController
view.setTextFrom(status: status)
view.isUserInteractionEnabled = false
view.backgroundColor = .clear
return view
}
func updateUIView(_ uiView: ComposeReplyContentTextView, context: Context) {
uiView.heightChanged = heightChanged
}
}
class ComposeReplyContentTextView: StatusContentTextView {
var heightChanged: ((CGFloat) -> Void)?
override func layoutSubviews() {
super.layoutSubviews()
heightChanged?(contentSize.height)
}
}

View File

@ -0,0 +1,67 @@
//
// ComposeReplyView.swift
// Tusker
//
// Created by Shadowfacts on 8/22/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
struct ComposeReplyView: View {
let status: StatusMO
let stackPadding: CGFloat
let outerMinY: CGFloat
@State private var contentHeight: CGFloat?
private let horizSpacing: CGFloat = 8
var body: some View {
HStack(alignment: .top, spacing: horizSpacing) {
GeometryReader(content: self.replyAvatarImage)
.frame(width: 50)
VStack(alignment: .leading, spacing: 0) {
HStack {
AccountDisplayNameLabel(account: status.account, fontSize: 17)
.lineLimit(1)
.layoutPriority(1)
Text(verbatim: "@\(status.account.acct)")
.font(.system(size: 17, weight: .light))
.foregroundColor(.secondary)
.lineLimit(1)
Spacer()
}
ComposeReplyContentView(status: status) { (newHeight) in
self.contentHeight = newHeight
}
.frame(height: contentHeight)
.offset(x: -4, y: -8)
.padding(.bottom, -8)
}
.frame(minHeight: 50 + 8)
}
.padding(.bottom, -8)
}
private func replyAvatarImage(geometry: GeometryProxy) -> some View {
// using named coordinate spaces produces an incorrect scroll offset on iOS 13,
// so simply compare the geometry inside and outside the scroll view in the global coordinate space
var scrollOffset = outerMinY - geometry.frame(in: .global).minY
scrollOffset += stackPadding
let offset = min(max(scrollOffset, 0), geometry.size.height - 50 - stackPadding)
return ComposeAvatarImageView(url: status.account.avatar)
.offset(x: 0, y: offset)
}
}
//struct ComposeReplyView_Previews: PreviewProvider {
// static var previews: some View {
// ComposeReplyView()
// }
//}

View File

@ -0,0 +1,125 @@
//
// ComposeTextView.swift
// Tusker
//
// Created by Shadowfacts on 8/18/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
struct ComposeTextView: View {
@Binding private var text: String
private let placeholder: Text?
private let minHeight: CGFloat
private var heightDidChange: ((CGFloat) -> Void)?
private var backgroundColor = UIColor.secondarySystemBackground
private var fontSize: CGFloat = 20
@State private var height: CGFloat?
init(text: Binding<String>, placeholder: Text?, minHeight: CGFloat = 150) {
self._text = text
self.placeholder = placeholder
self.minHeight = minHeight
}
var body: some View {
ZStack(alignment: .topLeading) {
WrappedTextView(
text: $text,
textDidChange: self.textDidChange,
backgroundColor: backgroundColor,
font: .systemFont(ofSize: fontSize)
)
.frame(height: height ?? minHeight)
if text.isEmpty, let placeholder = placeholder {
placeholder
.font(.system(size: fontSize))
.foregroundColor(.secondary)
.offset(x: 4, y: 8)
}
}
}
private func textDidChange(textView: UITextView) {
height = max(minHeight, textView.contentSize.height)
heightDidChange?(height!)
}
func heightDidChange(_ callback: @escaping (CGFloat) -> Void) -> Self {
var copy = self
copy.heightDidChange = callback
return copy
}
func backgroundColor(_ color: UIColor) -> Self {
var copy = self
copy.backgroundColor = color
return copy
}
func fontSize(_ size: CGFloat) -> Self {
var copy = self
copy.fontSize = size
return copy
}
}
struct WrappedTextView: UIViewRepresentable {
typealias UIViewType = UITextView
@Binding var text: String
var textDidChange: ((UITextView) -> Void)?
var backgroundColor = UIColor.secondarySystemBackground
var font = UIFont.systemFont(ofSize: 20)
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.delegate = context.coordinator
textView.isEditable = true
textView.backgroundColor = backgroundColor
textView.font = font
textView.textContainer.lineBreakMode = .byWordWrapping
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = text
context.coordinator.text = $text
context.coordinator.didChange = textDidChange
// wait until the next runloop iteration so that SwiftUI view updates have finished and
// the text view knows its new content size
DispatchQueue.main.async {
self.textDidChange?(uiView)
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(text: $text, didChange: textDidChange)
}
class Coordinator: NSObject, UITextViewDelegate {
var text: Binding<String>
var didChange: ((UITextView) -> Void)?
init(text: Binding<String>, didChange: ((UITextView) -> Void)?) {
self.text = text
self.didChange = didChange
}
func textViewDidChange(_ textView: UITextView) {
text.wrappedValue = textView.text
didChange?(textView)
}
}
}
//struct ComposeTextView_Previews: PreviewProvider {
// static var previews: some View {
// ComposeTextView()
// }
//}

View File

@ -0,0 +1,44 @@
//
// ComposeUIState.swift
// Tusker
//
// Created by Shadowfacts on 8/24/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
protocol ComposeUIStateDelegate: class {
var assetPickerDelegate: AssetPickerViewControllerDelegate? { get }
func dismissCompose()
func presentAssetPickerSheet()
func presentComposeDrawing()
func keyboardWillShow(accessoryView: UIView, notification: Notification)
func keyboardWillHide(accessoryView: UIView, notification: Notification)
func keyboardDidHide(accessoryView: UIView, notification: Notification)
}
class ComposeUIState: ObservableObject {
weak var delegate: ComposeUIStateDelegate?
@Published var draft: Draft
@Published var isShowingSaveDraftSheet = false
@Published var attachmentsMissingDescriptions = Set<UUID>()
var composeDrawingMode: ComposeDrawingMode?
init(draft: Draft) {
self.draft = draft
}
}
extension ComposeUIState {
enum ComposeDrawingMode {
case createNew
case edit(id: UUID)
}
}

View File

@ -0,0 +1,341 @@
//
// ComposeView.swift
// Tusker
//
// Created by Shadowfacts on 8/18/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
import Pachyderm
import Combine
struct ComposeView: View {
@ObservedObject var draft: Draft
@EnvironmentObject var mastodonController: MastodonController
@EnvironmentObject var uiState: ComposeUIState
@State private var isPosting = false
@State private var postProgress: Double = 0
@State private var postTotalProgress: Double = 0
@State private var isShowingPostErrorAlert = false
@State private var postError: PostError?
private let stackPadding: CGFloat = 8
init(draft: Draft) {
self.draft = draft
}
var charactersRemaining: Int {
let limit = mastodonController.instance.maxStatusCharacters ?? 500
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
return limit - (cwCount + CharacterCounter.count(text: draft.text))
}
var requiresAttachmentDescriptions: Bool {
guard Preferences.shared.requireAttachmentDescriptions else { return false }
let attachmentIds = draft.attachments.map(\.id)
return attachmentIds.contains { uiState.attachmentsMissingDescriptions.contains($0) }
}
var postButtonEnabled: Bool {
draft.hasContent && charactersRemaining >= 0 && !isPosting && !requiresAttachmentDescriptions
}
var body: some View {
// the pre-iOS 14 API does not result in the correct pointer interactions for nav bar buttons, see FB8595468
if #available(iOS 14.0, *) {
mostOfTheBody.toolbar {
ToolbarItem(placement: .cancellationAction) { cancelButton }
ToolbarItem(placement: .confirmationAction) { postButton }
}
} else {
mostOfTheBody.navigationBarItems(leading: cancelButton, trailing: postButton)
}
}
var mostOfTheBody: some View {
ZStack(alignment: .top) {
GeometryReader { (outer) in
ScrollView(.vertical) {
mainStack(outerMinY: outer.frame(in: .global).minY)
}
}
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
WrappedProgressView(value: postProgress, total: postTotalProgress)
}
.onAppear(perform: self.didAppear)
.navigationBarTitle("Compose")
.actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet)
.alert(isPresented: $isShowingPostErrorAlert) {
Alert(
title: Text("Error Posting Status"),
message: Text(postError?.localizedDescription ?? ""),
dismissButton: .default(Text("OK"))
)
}
}
func mainStack(outerMinY: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 8) {
if let id = draft.inReplyToID,
let status = mastodonController.persistentContainer.status(for: id) {
ComposeReplyView(
status: status,
stackPadding: stackPadding,
outerMinY: outerMinY
)
}
header
if draft.contentWarningEnabled {
TextField("Write your warning here", text: $draft.contentWarning)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
MainComposeTextView(
draft: draft,
placeholder: Text("What's on your mind?")
)
ComposeAttachmentsList(
draft: draft
)
// the list rows provide their own padding, so we cancel out the extra spacing from the VStack
.padding([.top, .bottom], -8)
}
.padding(stackPadding)
}
private var header: some View {
HStack(alignment: .top) {
ComposeCurrentAccount()
Spacer()
Text(verbatim: charactersRemaining.description)
.foregroundColor(charactersRemaining < 0 ? .red : .secondary)
.font(Font.body.monospacedDigit())
.accessibility(label: Text(charactersRemaining < 0 ? "\(-charactersRemaining) characters too many" : "\(charactersRemaining) characters remaining"))
}.frame(height: 50)
}
private var cancelButton: some View {
Button(action: self.cancel) {
Text("Cancel")
// otherwise all Buttons in the nav bar are made semibold
.font(.system(size: 17, weight: .regular))
}
}
private var postButton: some View {
Button(action: self.postStatus) {
Text("Post")
}
.disabled(!postButtonEnabled)
}
private func didAppear() {
let proxy = UIScrollView.appearance(whenContainedInInstancesOf: [ComposeHostingController.self])
proxy.keyboardDismissMode = .interactive
}
private func cancel() {
if Preferences.shared.automaticallySaveDrafts {
// draft is already stored in drafts manager, drafts manager is saved by ComposeHostingController.viewWillDisappear
uiState.delegate?.dismissCompose()
} else {
// if the draft doesn't have content, it doesn't need to be saved
if draft.hasContent {
uiState.isShowingSaveDraftSheet = true
} else {
DraftsManager.shared.remove(draft)
uiState.delegate?.dismissCompose()
}
}
}
private func saveAndCloseSheet() -> ActionSheet {
ActionSheet(title: Text("Do you want to save the current post as a draft?"), buttons: [
.default(Text("Save Draft"), action: {
// draft is already stored in drafts manager, drafts manager is saved by ComposeHostingController.viewWillDisappear
uiState.isShowingSaveDraftSheet = false
uiState.delegate?.dismissCompose()
}),
.destructive(Text("Delete Draft"), action: {
DraftsManager.shared.remove(draft)
uiState.isShowingSaveDraftSheet = false
uiState.delegate?.dismissCompose()
}),
.cancel(),
])
}
private func postStatus() {
guard draft.hasContent else { return }
isPosting = true
// save before posting, so if a crash occurs during network request, the status won't be lost
DraftsManager.save()
let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : nil
let sensitive = contentWarning != nil
// 2 steps (request data, then upload) for each attachment
postTotalProgress = Double(2 + (draft.attachments.count * 2))
postProgress = 1
uploadAttachments { (result) in
switch result {
case let .failure(error):
self.isShowingPostErrorAlert = true
self.postError = error
self.postProgress = 0
self.postTotalProgress = 0
self.isPosting = false
case let .success(uploadedAttachments):
let request = Client.createStatus(text: draft.text,
contentType: Preferences.shared.statusContentType,
inReplyTo: draft.inReplyToID,
media: uploadedAttachments,
sensitive: sensitive,
spoilerText: contentWarning,
visibility: draft.visibility,
language: nil)
self.mastodonController.run(request) { (response) in
switch response {
case let .failure(error):
self.isShowingPostErrorAlert = true
self.postError = error
case .success(_, _):
self.postProgress += 1
DraftsManager.shared.remove(self.draft)
// wait .25 seconds so the user can see the progress bar has completed
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) {
self.uiState.delegate?.dismissCompose()
}
}
}
}
}
}
private func uploadAttachments(_ completion: @escaping (Result<[Attachment], AttachmentUploadError>) -> Void) {
let group = DispatchGroup()
var attachmentDatas = [(Data, String)?]()
for (index, compAttachment) in draft.attachments.enumerated() {
group.enter()
attachmentDatas.append(nil)
compAttachment.data.getData { (data, mimeType) in
postProgress += 1
attachmentDatas[index] = (data, mimeType)
group.leave()
}
}
group.notify(queue: .global(qos: .userInitiated)) {
var anyFailed = false
var uploadedAttachments = [Result<Attachment, Error>?]()
// Mastodon does not respect the order of the `media_ids` parameter in the create post request,
// it determines attachment order by which was uploaded first. Since the upload attachment request
// does not include any timestamp data, and requests may arrive at the server out-of-order,
// attachments need to be uploaded serially in order to ensure the order of attachments in the
// posted status reflects order the user set.
// Pleroma does respect the order of the `media_ids` parameter.
for (index, (data, mimeType)) in attachmentDatas.map(\.unsafelyUnwrapped).enumerated() {
group.enter()
let compAttachment = draft.attachments[index]
let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: "file")
let request = Client.upload(attachment: formAttachment, description: compAttachment.attachmentDescription)
self.mastodonController.run(request) { (response) in
switch response {
case let .failure(error):
uploadedAttachments.append(.failure(error))
anyFailed = true
case let .success(attachment, _):
self.postProgress += 1
uploadedAttachments.append(.success(attachment))
}
group.leave()
}
group.wait()
}
if anyFailed {
let errors = uploadedAttachments.map { (result) -> Error? in
if case let .failure(error) = result {
return error
} else {
return nil
}
}
completion(.failure(AttachmentUploadError(errors: errors)))
} else {
let uploadedAttachments = uploadedAttachments.map {
try! $0!.get()
}
completion(.success(uploadedAttachments))
}
}
}
}
fileprivate protocol PostError: LocalizedError {}
extension PostError {
var localizedDescription: String {
if let self = self as? Client.Error {
return self.localizedDescription
} else if let self = self as? AttachmentUploadError {
return self.localizedDescription
} else {
return "Unknown Error"
}
}
}
extension Client.Error: PostError {}
fileprivate struct AttachmentUploadError: PostError {
let errors: [Error?]
var localizedDescription: String {
return errors.enumerated().compactMap { (index, error) -> String? in
guard let error = error else { return nil }
let description: String
// need to downcast to use more specific localizedDescription impl from Pachyderm
if let error = error as? Client.Error {
description = error.localizedDescription
} else {
description = error.localizedDescription
}
return "Attachment \(index + 1): \(description)"
}.joined(separator: ",\n")
}
}
//struct ComposeView_Previews: PreviewProvider {
// static var previews: some View {
// ComposeView()
// }
//}

View File

@ -1,618 +0,0 @@
//
// ComposeViewController.swift
// Tusker
//
// Created by Shadowfacts on 8/28/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import Intents
class ComposeViewController: UIViewController {
weak var mastodonController: MastodonController!
var inReplyToID: String?
var accountsToMention = [String]()
var initialText: String?
var contentWarningEnabled = false {
didSet {
contentWarningStateChanged()
}
}
var visibility: Status.Visibility! {
didSet {
visibilityChanged()
}
}
var hasChanges = false
var currentDraft: DraftsManager.Draft?
// Weak so that if a new session is initiated (i.e. XCBManager.currentSession is changed) while the current one is in progress, this one will be released
weak var xcbSession: XCBSession?
var postedStatus: Status?
var compositionState: CompositionState = .valid {
didSet {
postBarButtonItem.isEnabled = compositionState.isValid
}
}
weak var postBarButtonItem: UIBarButtonItem!
var visibilityBarButtonItem: UIBarButtonItem!
var contentWarningBarButtonItem: UIBarButtonItem!
@IBOutlet weak var scrollView: UIScrollView!
@IBOutlet weak var contentView: UIView!
@IBOutlet weak var stackView: UIStackView!
var replyView: ComposeStatusReplyView?
var replyAvatarImageViewTopConstraint: NSLayoutConstraint?
@IBOutlet weak var selfDetailView: LargeAccountDetailView!
@IBOutlet weak var charactersRemainingLabel: UILabel!
@IBOutlet weak var statusTextView: UITextView!
@IBOutlet weak var placeholderLabel: UILabel!
@IBOutlet weak var inReplyToContainer: UIView!
@IBOutlet weak var inReplyToLabel: UILabel!
@IBOutlet weak var contentWarningContainerView: UIView!
@IBOutlet weak var contentWarningTextField: UITextField!
@IBOutlet weak var composeAttachmentsContainerView: UIView!
@IBOutlet weak var postProgressView: SteppedProgressView!
var composeAttachmentsViewController: ComposeAttachmentsViewController!
init(inReplyTo inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil, mastodonController: MastodonController) {
self.mastodonController = mastodonController
self.inReplyToID = inReplyToID
if let inReplyToID = inReplyToID, let inReplyTo = mastodonController.persistentContainer.status(for: inReplyToID) {
accountsToMention = [inReplyTo.account.acct] + inReplyTo.mentions.map { $0.acct }
} else {
accountsToMention = []
}
if let mentioningAcct = mentioningAcct {
accountsToMention.append(mentioningAcct)
}
if let ownAccount = mastodonController.account {
accountsToMention.removeAll(where: { acct in ownAccount.acct == acct })
}
accountsToMention = accountsToMention.uniques()
super.init(nibName: "ComposeViewController", bundle: nil)
title = "Compose"
tabBarItem.image = UIImage(systemName: "pencil")
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(showSaveAndClosePrompt))
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Post", style: .done, target: self, action: #selector(postButtonPressed))
postBarButtonItem = navigationItem.rightBarButtonItem
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
scrollView.delegate = self
statusTextView.delegate = self
statusTextView.becomeFirstResponder()
let toolbar = UIToolbar()
contentWarningBarButtonItem = UIBarButtonItem(title: "CW", style: .plain, target: self, action: #selector(contentWarningButtonPressed))
contentWarningBarButtonItem.accessibilityLabel = NSLocalizedString("Add Content Warning", comment: "add CW accessibility label")
visibilityBarButtonItem = UIBarButtonItem(image: UIImage(systemName: Preferences.shared.defaultPostVisibility.imageName), style: .plain, target: self, action: #selector(visibilityButtonPressed))
visibilityBarButtonItem.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), Preferences.shared.defaultPostVisibility.displayName)
toolbar.items = [
contentWarningBarButtonItem,
visibilityBarButtonItem,
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
] + createFormattingButtons() + [
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
UIBarButtonItem(title: "Drafts", style: .plain, target: self, action: #selector(draftsButtonPressed))
]
toolbar.translatesAutoresizingMaskIntoConstraints = false
statusTextView.inputAccessoryView = toolbar
contentWarningTextField.inputAccessoryView = toolbar
statusTextView.text = accountsToMention.map({ acct in "@\(acct) " }).joined()
initialText = statusTextView.text
mastodonController.getOwnAccount { (account) in
DispatchQueue.main.async {
self.selfDetailView.update(account: account)
}
}
updateInReplyTo()
// we have to set the font here, because the monospaced digit font is not available in IB
charactersRemainingLabel.font = .monospacedDigitSystemFont(ofSize: 17, weight: .regular)
updatePlaceholder()
// if the compose screen is opened via the home screen shortcut and app isn't running,
// the msatodon instance may not have been loaded yet
mastodonController.getOwnInstance { (_) in
DispatchQueue.main.async {
self.updateCharactersRemaining()
}
}
composeAttachmentsViewController = ComposeAttachmentsViewController(attachments: currentDraft?.attachments ?? [], mastodonController: mastodonController)
composeRequiresAttachmentDescriptionsDidChange()
composeAttachmentsViewController.delegate = self
composeAttachmentsViewController.tableView.isScrollEnabled = false
composeAttachmentsViewController.tableView.translatesAutoresizingMaskIntoConstraints = false
embedChild(composeAttachmentsViewController, in: composeAttachmentsContainerView)
pasteConfiguration = composeAttachmentsViewController.pasteConfiguration
NotificationCenter.default.addObserver(self, selector: #selector(contentWarningTextFieldDidChange), name: UITextField.textDidChangeNotification, object: contentWarningTextField)
}
func updateInReplyTo() {
if let replyView = replyView {
replyView.removeFromSuperview()
}
if let inReplyToID = inReplyToID {
if let status = mastodonController.persistentContainer.status(for: inReplyToID) {
updateInReplyTo(inReplyTo: status)
} else {
let loadingVC = LoadingViewController()
embedChild(loadingVC)
let request = Client.getStatus(id: inReplyToID)
mastodonController.run(request) { (response) in
guard case let .success(status, _) = response else { return }
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: true) { (status) in
DispatchQueue.main.async {
self.updateInReplyTo(inReplyTo: status)
loadingVC.removeViewAndController()
}
}
}
}
} else {
visibility = Preferences.shared.defaultPostVisibility
contentWarningEnabled = false
inReplyToContainer.isHidden = true
}
}
func updateInReplyTo(inReplyTo: StatusMO) {
visibility = inReplyTo.visibility
if Preferences.shared.contentWarningCopyMode == .doNotCopy {
contentWarningEnabled = false
contentWarningContainerView.isHidden = true
} else {
contentWarningEnabled = !inReplyTo.spoilerText.isEmpty
contentWarningContainerView.isHidden = !contentWarningEnabled
if Preferences.shared.contentWarningCopyMode == .prependRe,
!inReplyTo.spoilerText.lowercased().starts(with: "re:") {
contentWarningTextField.text = "re: \(inReplyTo.spoilerText)"
} else {
contentWarningTextField.text = inReplyTo.spoilerText
}
}
let replyView = ComposeStatusReplyView.create()
replyView.mastodonController = mastodonController
replyView.updateUI(for: inReplyTo)
stackView.insertArrangedSubview(replyView, at: 0)
self.replyView = replyView
replyAvatarImageViewTopConstraint = replyView.avatarImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8)
replyAvatarImageViewTopConstraint!.isActive = true
inReplyToContainer.isHidden = false
// todo: update to use managed objects
inReplyToLabel.text = "In reply to \(inReplyTo.account.displayName)"
}
override func viewWillAppear(_ animated: Bool) {
NotificationCenter.default.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillHideNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
}
func createFormattingButtons() -> [UIBarButtonItem] {
guard Preferences.shared.statusContentType != .plain else {
return []
}
var formatButtons = StatusFormat.allCases.map { (format) -> UIBarButtonItem in
let item: UIBarButtonItem
if let image = format.image {
item = UIBarButtonItem(image: image, style: .plain, target: self, action: #selector(formatButtonPressed(_:)))
} else if let (str, attributes) = format.title {
item = UIBarButtonItem(title: str, style: .plain, target: self, action: #selector(formatButtonPressed(_:)))
item.setTitleTextAttributes(attributes, for: .normal)
item.setTitleTextAttributes(attributes, for: .highlighted)
} else {
fatalError("StatusFormat must have either an image or a title")
}
item.tag = StatusFormat.allCases.firstIndex(of: format)!
item.accessibilityLabel = format.accessibilityLabel
return item
}
for i in (1..<StatusFormat.allCases.count).reversed() {
let spacer = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil)
spacer.width = 8
formatButtons.insert(spacer, at: i)
}
return formatButtons
}
@objc func adjustForKeyboard(notification: NSNotification) {
let userInfo = notification.userInfo!
let keyboardScreenEndFrame = (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! NSValue).cgRectValue
let keyboardViewEndFrame = view.convert(keyboardScreenEndFrame, from: view.window)
if notification.name == UIResponder.keyboardWillHideNotification {
scrollView.contentInset = .zero
} else {
// let accessoryFrame = view.convert(statusTextView.inputAccessoryView!.frame, from: view.window)
let offset = keyboardViewEndFrame.height// + accessoryFrame.height
// TODO: radar for incorrect keyboard end frame height (either converted or screen)
// the value returned is somewhere between the height of the keyboard and the height of the keyboard + accessory
// actually maybe not??
scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: offset, right: 0)
}
scrollView.scrollIndicatorInsets = scrollView.contentInset
}
func updateCharactersRemaining() {
let count = CharacterCounter.count(text: statusTextView.text)
let cwCount = contentWarningEnabled ? (contentWarningTextField.text?.count ?? 0) : 0
let remaining = (mastodonController.instance?.maxStatusCharacters ?? 500) - count - cwCount
if remaining < 0 {
charactersRemainingLabel.textColor = .red
compositionState.formUnion(.tooManyCharacters)
} else {
charactersRemainingLabel.textColor = .darkGray
compositionState.subtract(.tooManyCharacters)
}
charactersRemainingLabel.text = String(remaining)
charactersRemainingLabel.accessibilityLabel = String(format: NSLocalizedString("%d characters remaining", comment: "compose characters remaining accessibility label"), remaining)
}
func updateHasChanges() {
if let currentDraft = currentDraft {
let cw = contentWarningEnabled ? contentWarningTextField.text : nil
hasChanges = statusTextView.text != currentDraft.text || cw != currentDraft.contentWarning
} else {
hasChanges = !statusTextView.text.isEmpty || (contentWarningEnabled && !(contentWarningTextField.text?.isEmpty ?? true))
}
}
func updatePlaceholder() {
placeholderLabel.isHidden = !statusTextView.text.isEmpty
}
func contentWarningStateChanged() {
contentWarningContainerView.isHidden = !contentWarningEnabled
if contentWarningEnabled {
contentWarningBarButtonItem.accessibilityLabel = NSLocalizedString("Remove Content Warning", comment: "remove CW accessibility label")
} else {
contentWarningBarButtonItem.accessibilityLabel = NSLocalizedString("Add Content Warning", comment: "add CW accessibility label")
}
}
func visibilityChanged() {
visibilityBarButtonItem.image = UIImage(systemName: visibility.imageName)
visibilityBarButtonItem.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), Preferences.shared.defaultPostVisibility.displayName)
}
func saveDraft() {
let attachments = composeAttachmentsViewController.attachments
let statusText = statusTextView.text.trimmingCharacters(in: .whitespacesAndNewlines)
let cw = contentWarningEnabled ? contentWarningTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) : nil
let account = mastodonController.accountInfo!
if attachments.count == 0, statusText.isEmpty, cw?.isEmpty ?? true {
if let currentDraft = self.currentDraft {
DraftsManager.shared.remove(currentDraft)
} else {
return
}
} else {
if let currentDraft = self.currentDraft {
currentDraft.update(accountID: account.id, text: statusText, contentWarning: cw, attachments: attachments)
} else {
self.currentDraft = DraftsManager.shared.create(accountID: account.id, text: statusText, contentWarning: cw, inReplyToID: inReplyToID, attachments: attachments)
}
}
DraftsManager.save()
}
@objc func close() {
dismiss(animated: true)
xcbSession?.complete(with: .cancel)
}
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
return composeAttachmentsViewController.canPaste(itemProviders)
}
override func paste(itemProviders: [NSItemProvider]) {
composeAttachmentsViewController.paste(itemProviders: itemProviders)
}
// MARK: - Interaction
@objc func showSaveAndClosePrompt() {
guard statusTextView.text.trimmingCharacters(in: .whitespacesAndNewlines) != initialText else {
close()
return
}
if Preferences.shared.automaticallySaveDrafts {
saveDraft()
close()
return
}
let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
alert.addAction(UIAlertAction(title: "Save draft", style: .default, handler: { (_) in
self.saveDraft()
self.close()
}))
alert.addAction(UIAlertAction(title: "Delete draft", style: .destructive, handler: { (_) in
if let currentDraft = self.currentDraft {
DraftsManager.shared.remove(currentDraft)
}
self.close()
}))
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
present(alert, animated: true)
}
@objc func contentWarningButtonPressed() {
contentWarningEnabled = !contentWarningEnabled
if contentWarningEnabled {
contentWarningTextField.becomeFirstResponder()
} else {
statusTextView.becomeFirstResponder()
}
}
@objc func contentWarningTextFieldDidChange() {
updateCharactersRemaining()
updateHasChanges()
}
@objc func visibilityButtonPressed() {
let alertController = UIAlertController(currentVisibility: self.visibility) { (visibility) in
guard let visibility = visibility else { return }
self.visibility = visibility
}
present(alertController, animated: true)
}
@objc func formatButtonPressed(_ button: UIBarButtonItem) {
guard statusTextView.isFirstResponder else {
return
}
let format = StatusFormat.allCases[button.tag]
guard let insertionResult = format.insertionResult else {
return
}
let currentSelectedRange = statusTextView.selectedRange
if currentSelectedRange.length == 0 {
statusTextView.insertText(insertionResult.prefix + insertionResult.suffix)
statusTextView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: 0)
} else {
let start = statusTextView.text.index(statusTextView.text.startIndex, offsetBy: currentSelectedRange.lowerBound)
let end = statusTextView.text.index(statusTextView.text.startIndex, offsetBy: currentSelectedRange.upperBound)
let selectedText = statusTextView.text[start..<end]
statusTextView.insertText(String(insertionResult.prefix + selectedText + insertionResult.suffix))
statusTextView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.prefix.count, length: currentSelectedRange.length)
}
}
@objc func draftsButtonPressed() {
let draftsVC = DraftsTableViewController(account: mastodonController.accountInfo!)
draftsVC.delegate = self
present(UINavigationController(rootViewController: draftsVC), animated: true)
}
@objc func postButtonPressed() {
guard let text = statusTextView.text,
!text.isEmpty else { return }
// save a draft before posting the status, so if a crash occurs during posting, the status won't be lost
saveDraft()
// disable post button while sending post request
compositionState.formUnion(.currentlyPosting)
let contentWarning: String?
if contentWarningEnabled, let cwText = contentWarningTextField.text, !cwText.isEmpty {
contentWarning = cwText
} else {
contentWarning = nil
}
let sensitive = contentWarning != nil
let visibility = self.visibility!
postProgressView.steps = 2 + (composeAttachmentsViewController.attachments.count * 2) // 2 steps (request data, then upload) for each attachment
postProgressView.currentStep = 1
composeAttachmentsViewController.uploadAll(stepProgress: postProgressView.step) { (success, uploadedAttachments) in
guard success else { return }
let request = Client.createStatus(text: text,
contentType: Preferences.shared.statusContentType,
inReplyTo: self.inReplyToID,
media: uploadedAttachments,
sensitive: sensitive,
spoilerText: contentWarning,
visibility: visibility,
language: nil)
self.mastodonController.run(request) { (response) in
guard case let .success(status, _) = response else { fatalError() }
self.postedStatus = status
// self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: true)
if let draft = self.currentDraft {
DraftsManager.shared.remove(draft)
}
DispatchQueue.main.async {
self.postProgressView.step()
self.dismiss(animated: true)
// todo: this doesn't work
// let conversationVC = ConversationTableViewController(for: status.id, mastodonController: self.mastodonController)
// self.show(conversationVC, sender: self)
self.xcbSession?.complete(with: .success, additionalData: [
"statusURL": status.url?.absoluteString,
"statusURI": status.uri
])
}
}
}
}
}
extension ComposeViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard let replyView = replyView else { return }
var constant: CGFloat = 8
if scrollView.contentOffset.y < 0 {
constant -= scrollView.contentOffset.y
replyAvatarImageViewTopConstraint?.constant = 8 - scrollView.contentOffset.y
} else if scrollView.contentOffset.y > replyView.frame.height - replyView.avatarImageView.frame.height - 16 {
constant += replyView.frame.height - replyView.avatarImageView.frame.height - 16 - scrollView.contentOffset.y
}
replyAvatarImageViewTopConstraint?.constant = constant
}
}
extension ComposeViewController: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
updateCharactersRemaining()
updatePlaceholder()
updateHasChanges()
}
}
extension ComposeViewController: ComposeAttachmentsViewControllerDelegate {
func composeSelectedAttachmentsDidChange() {
currentDraft?.attachments = composeAttachmentsViewController.attachments
}
func composeRequiresAttachmentDescriptionsDidChange() {
if composeAttachmentsViewController.requiresAttachmentDescriptions {
compositionState.formUnion(.requiresAttachmentDescriptions)
} else {
compositionState.subtract(.requiresAttachmentDescriptions)
}
}
}
extension ComposeViewController: DraftsTableViewControllerDelegate {
func draftSelectionCanceled() {
}
func shouldSelectDraft(_ draft: DraftsManager.Draft, completion: @escaping (Bool) -> Void) {
if draft.inReplyToID != self.inReplyToID, hasChanges {
let alertController = UIAlertController(title: "Different Reply", message: "The selected draft is a reply to a different status, do you wish to use it?", preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { (_) in
completion(false)
}))
alertController.addAction(UIAlertAction(title: "Restore Draft", style: .default, handler: { (_) in
completion(true)
}))
// we can't present the alert ourselves, since the compose VC is already presenting the draft selector
// but presenting on the presented view controller seems hacky, is there a better way to do this?
presentedViewController!.present(alertController, animated: true)
} else {
completion(true)
}
}
func draftSelected(_ draft: DraftsManager.Draft) {
if hasChanges {
saveDraft()
}
self.currentDraft = draft
inReplyToID = draft.inReplyToID
updateInReplyTo()
statusTextView.text = draft.text
contentWarningEnabled = draft.contentWarning != nil
contentWarningTextField.text = draft.contentWarning
updatePlaceholder()
updateCharactersRemaining()
composeAttachmentsViewController.setAttachments(draft.attachments)
}
func draftSelectionCompleted() {
// todo: I don't think this can actually happen any more?
// check that all the assets from the draft have been added
if let currentDraft = currentDraft, composeAttachmentsViewController.attachments.count < currentDraft.attachments.count {
// some of the assets in the draft weren't loaded, so notify the user
let difference = currentDraft.attachments.count - composeAttachmentsViewController.attachments.count
// todo: localize me
let suffix = difference == 1 ? "" : "s"
let verb = difference == 1 ? "was" : "were"
let alertController = UIAlertController(title: "Missing Attachments", message: "\(difference) attachment\(suffix) \(verb) removed from the Photos Library and could not be loaded.", preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
present(alertController, animated: true)
}
}
}
extension ComposeViewController: UIAdaptivePresentationControllerDelegate {
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
return Preferences.shared.automaticallySaveDrafts || !hasChanges
}
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
showSaveAndClosePrompt()
}
// when the compose screen is dismissed interactively, close() isn't called, so we make sure to
// complete the X-Callback-URL session and save the draft is automatic saving is enabled
// (if automatic saving is off, the draft will get saved/discarded by the user when didAttemptToDismiss is called
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
if Preferences.shared.automaticallySaveDrafts {
saveDraft()
}
xcbSession?.complete(with: .cancel)
}
}

View File

@ -1,178 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16097" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="ComposeViewController" customModule="Tusker" customModuleProvider="target">
<connections>
<outlet property="charactersRemainingLabel" destination="PMB-Wa-Ht0" id="PN9-wr-Pzu"/>
<outlet property="composeAttachmentsContainerView" destination="YFf-I2-7eX" id="u0n-Xe-v09"/>
<outlet property="contentView" destination="pcX-rB-RxJ" id="o95-Qa-6N7"/>
<outlet property="contentWarningContainerView" destination="kU2-7l-MSy" id="Gnq-Jb-kCA"/>
<outlet property="contentWarningTextField" destination="T05-p6-vTz" id="Ivu-Ll-ByO"/>
<outlet property="inReplyToContainer" destination="2Dv-Q7-UEA" id="hfG-5j-G5R"/>
<outlet property="inReplyToLabel" destination="Y25-eP-tDE" id="9Ei-3s-dAx"/>
<outlet property="placeholderLabel" destination="EW3-YK-vPC" id="Rsw-Nv-TNz"/>
<outlet property="postProgressView" destination="Tq7-6P-hMT" id="amT-F1-JI0"/>
<outlet property="scrollView" destination="6Z0-Vy-hMX" id="ya0-2T-QaV"/>
<outlet property="selfDetailView" destination="zZ3-Gv-4P5" id="jou-Vl-TQE"/>
<outlet property="stackView" destination="bOB-hF-O9w" id="lD7-b2-MWl"/>
<outlet property="statusTextView" destination="9pn-0T-IHb" id="u7j-KW-zCo"/>
<outlet property="view" destination="7XG-Dk-OGm" id="09I-sr-hnP"/>
</connections>
</placeholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="7XG-Dk-OGm">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<scrollView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" ambiguous="YES" alwaysBounceVertical="YES" keyboardDismissMode="interactive" translatesAutoresizingMaskIntoConstraints="NO" id="6Z0-Vy-hMX">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<subviews>
<view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="pcX-rB-RxJ">
<rect key="frame" x="0.0" y="0.0" width="375" height="371.5"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" ambiguous="YES" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="bOB-hF-O9w">
<rect key="frame" x="0.0" y="0.0" width="375" height="419.5"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="6V0-mH-Mhu">
<rect key="frame" x="0.0" y="0.0" width="375" height="66"/>
<subviews>
<view contentMode="scaleToFill" placeholderIntrinsicWidth="infinite" placeholderIntrinsicHeight="66" translatesAutoresizingMaskIntoConstraints="NO" id="zZ3-Gv-4P5" customClass="LargeAccountDetailView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="336" height="66"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" notEnabled="YES"/>
</accessibility>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="252" verticalHuggingPriority="251" text="500" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="PMB-Wa-Ht0">
<rect key="frame" x="336" y="8" width="31" height="20.5"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstAttribute="trailing" secondItem="PMB-Wa-Ht0" secondAttribute="trailing" constant="8" id="5KJ-rz-Heh"/>
<constraint firstItem="zZ3-Gv-4P5" firstAttribute="leading" secondItem="6V0-mH-Mhu" secondAttribute="leading" id="f6Q-fK-zq1"/>
<constraint firstItem="zZ3-Gv-4P5" firstAttribute="top" secondItem="6V0-mH-Mhu" secondAttribute="top" id="fjf-mn-l9f"/>
<constraint firstItem="PMB-Wa-Ht0" firstAttribute="top" secondItem="6V0-mH-Mhu" secondAttribute="top" constant="8" id="q3V-aY-t9K"/>
<constraint firstAttribute="bottom" secondItem="zZ3-Gv-4P5" secondAttribute="bottom" id="rOO-0n-odM"/>
<constraint firstItem="PMB-Wa-Ht0" firstAttribute="leading" secondItem="zZ3-Gv-4P5" secondAttribute="trailing" id="sVv-tH-7eB"/>
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="2Dv-Q7-UEA">
<rect key="frame" x="0.0" y="66" width="375" height="33.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="In reply to Display Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Y25-eP-tDE">
<rect key="frame" x="4" y="8" width="367" height="21.5"/>
<constraints>
<constraint firstAttribute="height" constant="21.5" id="man-Xn-eVt"/>
</constraints>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="bottom" secondItem="Y25-eP-tDE" secondAttribute="bottom" constant="4" id="1sZ-CX-GDU"/>
<constraint firstAttribute="trailing" secondItem="Y25-eP-tDE" secondAttribute="trailing" constant="4" id="I31-Rs-QwW"/>
<constraint firstItem="Y25-eP-tDE" firstAttribute="leading" secondItem="2Dv-Q7-UEA" secondAttribute="leading" constant="4" id="kdQ-zs-u7N"/>
<constraint firstItem="Y25-eP-tDE" firstAttribute="top" secondItem="2Dv-Q7-UEA" secondAttribute="top" constant="8" id="qdC-S5-CgV"/>
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="kU2-7l-MSy">
<rect key="frame" x="0.0" y="99.5" width="375" height="42"/>
<subviews>
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" borderStyle="roundedRect" placeholder="Write your warning here" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="T05-p6-vTz">
<rect key="frame" x="4" y="4" width="367" height="30"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor" red="0.94901960780000005" green="0.94901960780000005" blue="0.96862745100000003" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstAttribute="height" constant="30" id="yzY-MF-Ukx"/>
</constraints>
<fontDescription key="fontDescription" type="system" pointSize="20"/>
<textInputTraits key="textInputTraits"/>
</textField>
</subviews>
<constraints>
<constraint firstAttribute="trailing" secondItem="T05-p6-vTz" secondAttribute="trailing" constant="4" id="8tG-eW-TG4"/>
<constraint firstAttribute="bottom" secondItem="T05-p6-vTz" secondAttribute="bottom" constant="8" id="SUL-Hk-uvM"/>
<constraint firstItem="T05-p6-vTz" firstAttribute="leading" secondItem="kU2-7l-MSy" secondAttribute="leading" constant="4" id="WGG-B2-lPC"/>
<constraint firstItem="T05-p6-vTz" firstAttribute="top" secondItem="kU2-7l-MSy" secondAttribute="top" constant="4" id="dN2-Pf-qFQ"/>
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="lhQ-ae-pe9">
<rect key="frame" x="0.0" y="141.5" width="375" height="150"/>
<subviews>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="9pn-0T-IHb">
<rect key="frame" x="4" y="0.0" width="367" height="150"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor" red="0.94901960780000005" green="0.94901960780000005" blue="0.96862745100000003" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstAttribute="height" relation="greaterThanOrEqual" constant="150" id="ISI-jm-FxV"/>
</constraints>
<fontDescription key="fontDescription" type="system" pointSize="20"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="What's on your mind?" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="EW3-YK-vPC">
<rect key="frame" x="8" y="8" width="188" height="24"/>
<fontDescription key="fontDescription" type="system" pointSize="20"/>
<color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstAttribute="bottom" secondItem="9pn-0T-IHb" secondAttribute="bottom" id="UAs-fL-Riv"/>
<constraint firstItem="9pn-0T-IHb" firstAttribute="leading" secondItem="lhQ-ae-pe9" secondAttribute="leading" constant="4" id="ezI-15-Yd4"/>
<constraint firstItem="9pn-0T-IHb" firstAttribute="top" secondItem="lhQ-ae-pe9" secondAttribute="top" id="n8v-pK-I9E"/>
<constraint firstItem="EW3-YK-vPC" firstAttribute="leading" secondItem="9pn-0T-IHb" secondAttribute="leading" constant="4" id="n9v-mJ-gz3"/>
<constraint firstItem="EW3-YK-vPC" firstAttribute="top" secondItem="9pn-0T-IHb" secondAttribute="top" constant="8" id="q5e-yM-bS4"/>
<constraint firstAttribute="trailing" secondItem="9pn-0T-IHb" secondAttribute="trailing" constant="4" id="x7Z-8w-xgm"/>
</constraints>
</view>
<view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="YFf-I2-7eX">
<rect key="frame" x="0.0" y="291.5" width="375" height="128"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
</view>
</subviews>
</stackView>
</subviews>
<constraints>
<constraint firstAttribute="trailing" secondItem="bOB-hF-O9w" secondAttribute="trailing" id="GAR-qc-jte"/>
<constraint firstAttribute="height" secondItem="bOB-hF-O9w" secondAttribute="height" id="KO2-zF-s7P"/>
<constraint firstItem="bOB-hF-O9w" firstAttribute="top" secondItem="pcX-rB-RxJ" secondAttribute="top" id="aBm-Ub-TpI"/>
<constraint firstItem="bOB-hF-O9w" firstAttribute="leading" secondItem="pcX-rB-RxJ" secondAttribute="leading" id="yOt-hH-L57"/>
</constraints>
</view>
</subviews>
<constraints>
<constraint firstItem="pcX-rB-RxJ" firstAttribute="leading" secondItem="6Z0-Vy-hMX" secondAttribute="leading" id="X0f-ja-XBF"/>
<constraint firstAttribute="bottom" secondItem="pcX-rB-RxJ" secondAttribute="bottom" id="X8a-PA-r8F"/>
<constraint firstAttribute="trailing" secondItem="pcX-rB-RxJ" secondAttribute="trailing" id="lh7-xn-MGp"/>
<constraint firstItem="pcX-rB-RxJ" firstAttribute="top" secondItem="6Z0-Vy-hMX" secondAttribute="top" id="yMM-IS-8K1"/>
</constraints>
</scrollView>
<progressView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Tq7-6P-hMT" customClass="SteppedProgressView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="2"/>
<color key="trackTintColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</progressView>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstAttribute="trailing" secondItem="Tq7-6P-hMT" secondAttribute="trailing" id="GeN-8q-weq"/>
<constraint firstAttribute="bottom" secondItem="6Z0-Vy-hMX" secondAttribute="bottom" id="Hf3-Cc-mVX"/>
<constraint firstItem="Tq7-6P-hMT" firstAttribute="top" secondItem="Heg-g4-sYM" secondAttribute="top" id="LgA-xu-VGE"/>
<constraint firstItem="Tq7-6P-hMT" firstAttribute="leading" secondItem="7XG-Dk-OGm" secondAttribute="leading" id="agM-ZO-c3E"/>
<constraint firstItem="Heg-g4-sYM" firstAttribute="trailing" secondItem="6Z0-Vy-hMX" secondAttribute="trailing" id="hjY-W6-wTQ"/>
<constraint firstItem="bOB-hF-O9w" firstAttribute="width" secondItem="7XG-Dk-OGm" secondAttribute="width" id="i0p-NE-ca1"/>
<constraint firstItem="6Z0-Vy-hMX" firstAttribute="leading" secondItem="Heg-g4-sYM" secondAttribute="leading" id="lFF-yC-ql9"/>
<constraint firstItem="6Z0-Vy-hMX" firstAttribute="top" secondItem="Heg-g4-sYM" secondAttribute="top" id="osv-zq-seP"/>
</constraints>
<viewLayoutGuide key="safeArea" id="Heg-g4-sYM"/>
<point key="canvasLocation" x="140" y="154"/>
</view>
</objects>
</document>

View File

@ -1,185 +0,0 @@
//
// CompositionAttachment.swift
// Tusker
//
// Created by Shadowfacts on 1/1/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Photos
import MobileCoreServices
enum CompositionAttachmentData {
case asset(PHAsset)
case image(UIImage)
case video(URL)
var type: AttachmentType {
switch self {
case let .asset(asset):
return asset.attachmentType!
case .image(_):
return .image
case .video(_):
return .video
}
}
var isAsset: Bool {
switch self {
case .asset(_):
return true
default:
return false
}
}
var canSaveToDraft: Bool {
switch self {
case .video(_):
return false
default:
return true
}
}
func getData(completion: @escaping (Data, String) -> Void) {
switch self {
case let .image(image):
completion(image.pngData()!, "image/png")
case let .asset(asset):
if asset.mediaType == .image {
let options = PHImageRequestOptions()
options.version = .current
options.deliveryMode = .highQualityFormat
options.resizeMode = .none
options.isNetworkAccessAllowed = true
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { (data, dataUTI, orientation, info) in
guard var data = data, let dataUTI = dataUTI else { fatalError() }
let mimeType: String
if dataUTI == "public.heic" {
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
let image = CIImage(data: data)!
let context = CIContext()
let colorSpace = image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!
data = context.jpegRepresentation(of: image, colorSpace: colorSpace, options: [:])!
mimeType = "image/jpeg"
} else {
mimeType = UTTypeCopyPreferredTagWithClass(dataUTI as CFString, kUTTagClassMIMEType)!.takeRetainedValue() as String
}
completion(data, mimeType)
}
} else if asset.mediaType == .video {
let options = PHVideoRequestOptions()
options.deliveryMode = .automatic
options.isNetworkAccessAllowed = true
options.version = .current
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { (exportSession, info) in
guard let exportSession = exportSession else { fatalError("failed to create export session") }
CompositionAttachmentData.exportVideoData(session: exportSession, completion: completion)
}
} else {
fatalError("assetType must be either image or video")
}
case let .video(url):
let asset = AVURLAsset(url: url)
guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
fatalError("failed to create export session")
}
CompositionAttachmentData.exportVideoData(session: session, completion: completion)
}
}
private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Data, String) -> Void) {
session.outputFileType = .mp4
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
session.exportAsynchronously {
guard session.status == .completed else { fatalError("video export failed: \(String(describing: session.error))") }
do {
let data = try Data(contentsOf: session.outputURL!)
completion(data, "video/mp4")
} catch {
fatalError("Unable to load video: \(error)")
}
}
}
enum AttachmentType {
case image, video
}
}
extension PHAsset {
var attachmentType: CompositionAttachmentData.AttachmentType? {
switch self.mediaType {
case .image:
return .image
case .video:
return .video
default:
return nil
}
}
}
extension CompositionAttachmentData: Codable {
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case let .asset(asset):
try container.encode("asset", forKey: .type)
try container.encode(asset.localIdentifier, forKey: .assetIdentifier)
case let .image(image):
try container.encode("image", forKey: .type)
try container.encode(image.pngData()!, forKey: .imageData)
case .video(_):
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: [], debugDescription: "video CompositionAttachments cannot be encoded"))
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
switch try container.decode(String.self, forKey: .type) {
case "asset":
let identifier = try container.decode(String.self, forKey: .assetIdentifier)
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil).firstObject else {
throw DecodingError.dataCorruptedError(forKey: .assetIdentifier, in: container, debugDescription: "Could not fetch asset with local identifier")
}
self = .asset(asset)
case "image":
guard let image = UIImage(data: try container.decode(Data.self, forKey: .imageData)) else {
throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "Could not decode UIImage from image data")
}
self = .image(image)
default:
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of 'image' or 'asset'")
}
}
enum CodingKeys: CodingKey {
case type
case imageData
/// The local identifier of the PHAsset for this attachment
case assetIdentifier
}
}
extension CompositionAttachmentData: Equatable {
static func ==(lhs: CompositionAttachmentData, rhs: CompositionAttachmentData) -> Bool {
switch (lhs, rhs) {
case let (.asset(a), .asset(b)):
return a.localIdentifier == b.localIdentifier
case let (.image(a), .image(b)):
return a == b
case let (.video(a), .video(b)):
return a == b
default:
return false
}
}
}

View File

@ -0,0 +1,217 @@
//
// MainComposeTextView.swift
// Tusker
//
// Created by Shadowfacts on 8/29/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
import Pachyderm
struct MainComposeTextView: View {
@ObservedObject var draft: Draft
let placeholder: Text
let minHeight: CGFloat = 150
@State private var height: CGFloat?
@State private var becomeFirstResponder: Bool = false
@State private var hasFirstAppeared = false
var body: some View {
ZStack(alignment: .topLeading) {
MainComposeWrappedTextView(
text: $draft.text,
visibility: draft.visibility,
becomeFirstResponder: $becomeFirstResponder
) { (textView) in
self.height = max(textView.contentSize.height, minHeight)
}
.frame(height: height ?? minHeight)
if draft.text.isEmpty {
placeholder
.font(.system(size: 20))
.foregroundColor(.secondary)
.offset(x: 4, y: 8)
}
}.onAppear {
if !hasFirstAppeared {
hasFirstAppeared = true
becomeFirstResponder = true
}
}
}
}
struct MainComposeWrappedTextView: UIViewRepresentable {
typealias UIViewType = UITextView
@Binding var text: String
let visibility: Status.Visibility
@Binding var becomeFirstResponder: Bool
var textDidChange: (UITextView) -> Void
@EnvironmentObject var uiState: ComposeUIState
@State var visibilityButton: UIBarButtonItem?
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.delegate = context.coordinator
textView.isEditable = true
textView.backgroundColor = .secondarySystemBackground
textView.font = .systemFont(ofSize: 20)
textView.textContainer.lineBreakMode = .byWordWrapping
context.coordinator.textView = textView
let visibilityAction: Selector?
if #available(iOS 14.0, *) {
visibilityAction = nil
} else {
visibilityAction = #selector(ComposeHostingController.visibilityButtonPressed(_:))
}
let visibilityButton = UIBarButtonItem(image: UIImage(systemName: visibility.imageName), style: .plain, target: nil, action: visibilityAction)
updateVisibilityMenu(visibilityButton)
let toolbar = UIToolbar()
toolbar.translatesAutoresizingMaskIntoConstraints = false
toolbar.items = [
UIBarButtonItem(title: "CW", style: .plain, target: nil, action: #selector(ComposeHostingController.cwButtonPressed)),
visibilityButton,
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
] + createFormattingButtons(coordinator: context.coordinator) + [
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
UIBarButtonItem(title: "Drafts", style: .plain, target: nil, action: #selector(ComposeHostingController.draftsButtonPresed)),
]
textView.inputAccessoryView = toolbar
// can't modify @State during view update
DispatchQueue.main.async {
self.visibilityButton = visibilityButton
}
NotificationCenter.default.addObserver(context.coordinator, selector: #selector(Coordinator.keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(context.coordinator, selector: #selector(Coordinator.keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
NotificationCenter.default.addObserver(context.coordinator, selector: #selector(Coordinator.keyboardDidHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil)
return textView
}
private func createFormattingButtons(coordinator: Coordinator) -> [UIBarButtonItem] {
guard Preferences.shared.statusContentType != .plain else {
return []
}
var formatButtons = StatusFormat.allCases.map { (format) -> UIBarButtonItem in
let item: UIBarButtonItem
if let image = format.image {
item = UIBarButtonItem(image: image, style: .plain, target: coordinator, action: #selector(Coordinator.formatButtonPressed(_:)))
} else if let (str, attributes) = format.title {
item = UIBarButtonItem(title: str, style: .plain, target: coordinator, action: #selector(Coordinator.formatButtonPressed(_:)))
item.setTitleTextAttributes(attributes, for: .normal)
item.setTitleTextAttributes(attributes, for: .highlighted)
} else {
fatalError("StatusFormat must have either an image or a title")
}
item.tag = StatusFormat.allCases.firstIndex(of: format)!
item.accessibilityLabel = format.accessibilityLabel
return item
}
for i in (1..<StatusFormat.allCases.count).reversed() {
let spacer = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil)
spacer.width = 8
formatButtons.insert(spacer, at: i)
}
return formatButtons
}
private func updateVisibilityMenu(_ visibilityButton: UIBarButtonItem) {
if #available(iOS 14.0, *) {
let elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in
let state = visibility == self.visibility ? UIMenuElement.State.on : .off
return UIAction(title: visibility.displayName, image: UIImage(systemName: visibility.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: state) { (_) in
self.uiState.draft.visibility = visibility
}
}
visibilityButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: elements)
}
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = text
if let visibilityButton = visibilityButton {
visibilityButton.image = UIImage(systemName: visibility.imageName)
updateVisibilityMenu(visibilityButton)
}
context.coordinator.text = $text
context.coordinator.didChange = textDidChange
context.coordinator.uiState = uiState
if becomeFirstResponder {
DispatchQueue.main.async {
// calling becomeFirstResponder during the SwiftUI update causes a crash on iOS 13
uiView.becomeFirstResponder()
// can't update @State vars during the SwiftUI update
becomeFirstResponder = false
}
}
// wait until the next runloop iteration so that SwiftUI view updates have finished and
// the text view knows its new content size
DispatchQueue.main.async {
self.textDidChange(uiView)
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(text: $text, uiState: uiState, didChange: textDidChange)
}
class Coordinator: NSObject, UITextViewDelegate {
weak var textView: UITextView?
var text: Binding<String>
var didChange: (UITextView) -> Void
var uiState: ComposeUIState
init(text: Binding<String>, uiState: ComposeUIState, didChange: @escaping (UITextView) -> Void) {
self.text = text
self.didChange = didChange
self.uiState = uiState
}
func textViewDidChange(_ textView: UITextView) {
text.wrappedValue = textView.text
didChange(textView)
}
@objc func formatButtonPressed(_ sender: UIBarButtonItem) {
guard let textView = textView, textView.isFirstResponder else { return }
let format = StatusFormat.allCases[sender.tag]
guard let insertionResult = format.insertionResult else { return }
let currentSelectedRange = textView.selectedRange
if currentSelectedRange.length == 0 {
textView.insertText(insertionResult.prefix + insertionResult.suffix)
textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: 0)
} else {
let start = textView.text.index(textView.text.startIndex, offsetBy: currentSelectedRange.lowerBound)
let end = textView.text.index(textView.text.startIndex, offsetBy: currentSelectedRange.upperBound)
let selectedText = textView.text[start..<end]
textView.insertText(String(insertionResult.prefix + selectedText + insertionResult.suffix))
textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.prefix.utf16.count, length: currentSelectedRange.length)
}
}
@objc func keyboardWillShow(_ notification: Foundation.Notification) {
uiState.delegate?.keyboardWillShow(accessoryView: textView!.inputAccessoryView!, notification: notification)
}
@objc func keyboardWillHide(_ notification: Foundation.Notification) {
uiState.delegate?.keyboardWillHide(accessoryView: textView!.inputAccessoryView!, notification: notification)
}
@objc func keyboardDidHide(_ notification: Foundation.Notification) {
uiState.delegate?.keyboardDidHide(accessoryView: textView!.inputAccessoryView!, notification: notification)
}
}
}

View File

@ -189,7 +189,7 @@ extension ConversationTableViewController: UITableViewDataSourcePrefetching {
for indexPath in indexPaths {
guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else { continue }
_ = ImageCache.avatars.get(status.account.avatar, completion: nil)
for attachment in status.attachments {
for attachment in status.attachments where attachment.kind == .image {
_ = ImageCache.attachments.get(attachment.url, completion: nil)
}
}
@ -199,7 +199,7 @@ extension ConversationTableViewController: UITableViewDataSourcePrefetching {
for indexPath in indexPaths {
guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else { continue }
ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
for attachment in status.attachments {
for attachment in status.attachments where attachment.kind == .image {
ImageCache.attachments.cancelWithoutCallback(attachment.url)
}
}

View File

@ -10,20 +10,22 @@ import UIKit
protocol DraftsTableViewControllerDelegate: class {
func draftSelectionCanceled()
func shouldSelectDraft(_ draft: DraftsManager.Draft, completion: @escaping (Bool) -> Void)
func draftSelected(_ draft: DraftsManager.Draft)
func shouldSelectDraft(_ draft: Draft, completion: @escaping (Bool) -> Void)
func draftSelected(_ draft: Draft)
func draftSelectionCompleted()
}
class DraftsTableViewController: UITableViewController {
let account: LocalData.UserAccountInfo
let excludedDraft: Draft?
weak var delegate: DraftsTableViewControllerDelegate?
var drafts = [DraftsManager.Draft]()
var drafts = [Draft]()
init(account: LocalData.UserAccountInfo) {
init(account: LocalData.UserAccountInfo, exclude: Draft? = nil) {
self.account = account
self.excludedDraft = exclude
super.init(nibName: "DraftsTableViewController", bundle: nil)
@ -44,11 +46,11 @@ class DraftsTableViewController: UITableViewController {
tableView.register(UINib(nibName: "DraftTableViewCell", bundle: nil), forCellReuseIdentifier: "draftCell")
drafts = DraftsManager.shared.sorted.filter { (draft) in
draft.accountID == account.id
draft.accountID == account.id && draft != excludedDraft
}
}
func draft(for indexPath: IndexPath) -> DraftsManager.Draft {
func draft(for indexPath: IndexPath) -> Draft {
return drafts[indexPath.row]
}

View File

@ -19,6 +19,8 @@ class ExploreViewController: EnhancedTableViewController {
var resultsController: SearchResultsViewController!
var searchController: UISearchController!
var searchControllerStatusOnAppearance: Bool? = nil
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
@ -90,9 +92,7 @@ class ExploreViewController: EnhancedTableViewController {
snapshot.appendItems(SavedDataManager.shared.sortedHashtags(for: account).map { .savedHashtag($0) } + [.addSavedHashtag], toSection: .savedHashtags)
snapshot.appendItems(SavedDataManager.shared.savedInstances(for: account).map { .savedInstance($0) } + [.findInstance], toSection: .savedInstances)
// the initial, static items should not be displayed with an animation
UIView.performWithoutAnimation {
dataSource.apply(snapshot)
}
dataSource.apply(snapshot, animatingDifferences: false)
resultsController = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController!
@ -111,6 +111,18 @@ class ExploreViewController: EnhancedTableViewController {
reloadLists()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// this is a workaround for the issue that setting isActive on a search controller that is not visible
// does not cause it to automatically become active once it becomes visible
// see FB7814561
if let active = searchControllerStatusOnAppearance {
searchController.isActive = active
searchControllerStatusOnAppearance = nil
}
}
func reloadLists() {
let request = Client.getLists()
mastodonController.run(request) { (response) in

View File

@ -46,6 +46,7 @@ extension FindInstanceViewController: InstanceSelectorTableViewControllerDelegat
func didSelectInstance(url: URL) {
let instanceTimelineController = InstanceTimelineViewController(for: url, parentMastodonController: parentMastodonController!)
instanceTimelineController.delegate = instanceTimelineDelegate
instanceTimelineController.browsingEnabled = false
show(instanceTimelineController, sender: self)
}
}

View File

@ -46,6 +46,8 @@ class LargeImageImageContentView: UIImageView, GIFAnimatable, LargeImageContentV
}
override public func display(_ layer: CALayer) {
super.display(layer)
updateImageIfNeeded()
}
}
@ -76,6 +78,8 @@ class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView {
super.init(asset: asset, gravity: .resizeAspect)
self.animationImage = source.image
self.player.play()
}
required init?(coder: NSCoder) {

View File

@ -13,6 +13,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
typealias ContentView = UIView & LargeImageContentView
weak var animationSourceView: UIImageView?
var largeImageController: LargeImageViewController? { self }
var animationImage: UIImage? { contentView.animationImage }
var animationGifData: Data? { contentView.animationGifData }
var dismissInteractionController: LargeImageInteractionController?
@ -30,7 +31,12 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
@IBOutlet weak var bottomControlsView: UIView!
@IBOutlet weak var descriptionLabel: UILabel!
var contentView: ContentView
var contentView: ContentView {
didSet {
oldValue.removeFromSuperview()
setupContentView()
}
}
var contentViewLeadingConstraint: NSLayoutConstraint!
var contentViewTopConstraint: NSLayoutConstraint!
@ -49,6 +55,9 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
override var prefersStatusBarHidden: Bool {
return true
}
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
return .none
}
override var prefersHomeIndicatorAutoHidden: Bool {
return !controlsVisible
@ -72,22 +81,16 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
override func viewDidLoad() {
super.viewDidLoad()
contentView.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(contentView)
contentViewLeadingConstraint = contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor)
contentViewTopConstraint = contentView.topAnchor.constraint(equalTo: scrollView.topAnchor)
NSLayoutConstraint.activate([
contentViewLeadingConstraint,
contentViewTopConstraint,
])
setupContentView()
setControlsVisible(initialControlsVisible, animated: false)
shareButton.isEnabled = !contentView.activityItemsForSharing.isEmpty
scrollView.delegate = self
if let imageDescription = imageDescription {
descriptionLabel.text = imageDescription
if let imageDescription = imageDescription,
!imageDescription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
descriptionLabel.text = imageDescription.trimmingCharacters(in: .whitespacesAndNewlines)
} else {
bottomControlsView.isHidden = true
}
@ -102,6 +105,17 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
view.addGestureRecognizer(doubleTap)
}
private func setupContentView() {
contentView.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(contentView)
contentViewLeadingConstraint = contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor)
contentViewTopConstraint = contentView.topAnchor.constraint(equalTo: scrollView.topAnchor)
NSLayoutConstraint.activate([
contentViewLeadingConstraint,
contentViewTopConstraint,
])
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
@ -119,8 +133,9 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
centerImage()
// todo: does this need to be in viewDidLayoutSubviews?
if view.safeAreaInsets.top == 44 {
// running on iPhone X style notched device
// on iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max, the top safe area inset is 44pts
// on iPhone XR, 11, the top inset is 48pts
if view.safeAreaInsets.top == 44 || view.safeAreaInsets.top == 48 {
let notchWidth: CGFloat = 209
let earWidth = (view.bounds.width - notchWidth) / 2
let offset = (earWidth - shareButton.bounds.width) / 2

View File

@ -1,8 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16097" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
@ -70,23 +71,25 @@
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="rPa-Zu-T6g">
<rect key="frame" x="0.0" y="630.5" width="375" height="36.5"/>
<rect key="frame" x="0.0" y="622.5" width="375" height="44.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="eo5-fc-RV8">
<rect key="frame" x="16" y="0.0" width="343" height="20.5"/>
<rect key="frame" x="16" y="8" width="343" height="20.5"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.5" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="eo5-fc-RV8" firstAttribute="top" secondItem="rPa-Zu-T6g" secondAttribute="top" constant="8" id="6n3-E0-2G6"/>
<constraint firstAttribute="trailing" secondItem="eo5-fc-RV8" secondAttribute="trailing" constant="16" id="6uL-vY-tqk"/>
<constraint firstItem="eo5-fc-RV8" firstAttribute="leading" secondItem="rPa-Zu-T6g" secondAttribute="leading" constant="16" id="KIF-vw-K7n"/>
<constraint firstAttribute="height" secondItem="eo5-fc-RV8" secondAttribute="height" constant="16" id="bt3-XT-WzC"/>
<constraint firstAttribute="bottom" secondItem="eo5-fc-RV8" secondAttribute="bottom" constant="16" id="v43-mS-tyR"/>
</constraints>
</view>
</subviews>
<viewLayoutGuide key="safeArea" id="w1g-VC-Ll9"/>
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<gestureRecognizers/>
<constraints>
@ -101,7 +104,6 @@
<constraint firstItem="Skj-xq-AgQ" firstAttribute="height" secondItem="BJw-5C-9nT" secondAttribute="height" id="jvz-QW-n9c"/>
<constraint firstItem="kHo-B9-R7a" firstAttribute="top" secondItem="BJw-5C-9nT" secondAttribute="top" id="n1O-C3-yQR"/>
</constraints>
<viewLayoutGuide key="safeArea" id="w1g-VC-Ll9"/>
<point key="canvasLocation" x="-164" y="476"/>
</view>
</objects>

View File

@ -10,14 +10,16 @@ import Pachyderm
class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableViewController {
private var attachment: Attachment?
let url: URL
let cache: ImageCache
let imageDescription: String?
var largeImageVC: LargeImageViewController?
var loadingVC: LoadingViewController?
private(set) var loaded = false
private(set) var largeImageVC: LargeImageViewController?
private var loadingVC: LoadingViewController?
var imageRequest: ImageCache.Request?
private var imageRequest: ImageCache.Request?
private var initialControlsVisible: Bool = true
var controlsVisible: Bool {
@ -36,6 +38,7 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
var shrinkGestureEnabled = true
weak var animationSourceView: UIImageView?
var largeImageController: LargeImageViewController? { largeImageVC }
var animationImage: UIImage? { largeImageVC?.animationImage ?? animationSourceView?.image }
var animationGifData: Data? { largeImageVC?.animationGifData }
var dismissInteractionController: LargeImageInteractionController?
@ -43,6 +46,9 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
override var prefersStatusBarHidden: Bool {
return true
}
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
return .none
}
override var childForHomeIndicatorAutoHidden: UIViewController? {
return largeImageVC
}
@ -66,6 +72,7 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
convenience init(attachment: Attachment) {
self.init(url: attachment.url, cache: .attachments, imageDescription: attachment.description)
self.attachment = attachment
}
required init?(coder: NSCoder) {
@ -81,6 +88,8 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
if let data = cache.get(url) {
createLargeImage(data: data)
} else {
createPreview()
loadingVC = LoadingViewController()
embedChild(loadingVC!)
imageRequest = cache.get(url) { [weak self] (data) in
@ -106,15 +115,32 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
}
}
func createLargeImage(data: Data) {
private func createLargeImage(data: Data) {
guard !loaded else { return }
loaded = true
guard let image = UIImage(data: data) else { return }
let gifData = url.pathExtension == "gif" ? data : nil
createLargeImage(image: image, gifData: gifData)
}
private func createLargeImage(image: UIImage, gifData: Data?) {
let imageView = LargeImageImageContentView(image: image, gifData: gifData)
if let existing = largeImageVC {
existing.contentView = imageView
} else {
largeImageVC = LargeImageViewController(contentView: imageView, description: imageDescription, sourceView: animationSourceView)
largeImageVC!.initialControlsVisible = initialControlsVisible
largeImageVC!.shrinkGestureEnabled = false
embedChild(largeImageVC!)
}
}
private func createPreview() {
guard !self.loaded,
let image = animationSourceView?.image else { return }
self.createLargeImage(image: image, gifData: nil)
}
}

View File

@ -11,6 +11,7 @@ import Gifu
protocol LargeImageAnimatableViewController: UIViewController {
var animationSourceView: UIImageView? { get }
var largeImageController: LargeImageViewController? { get }
var animationImage: UIImage? { get }
var animationGifData: Data? { get }
var dismissInteractionController: LargeImageInteractionController? { get }
@ -37,7 +38,11 @@ extension LargeImageAnimatableViewController {
class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
if UIAccessibility.prefersCrossFadeTransitionsBackwardsCompat {
return 0.2
} else {
return 0.4
}
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
@ -46,6 +51,11 @@ class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTra
return
}
if UIAccessibility.prefersCrossFadeTransitionsBackwardsCompat {
animateCrossFadeTransition(using: transitionContext)
return
}
let containerView = transitionContext.containerView
containerView.addSubview(toVC.view)
@ -58,8 +68,11 @@ class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTra
return
}
// use alpha, becaus isHidden makes stack views re-layout
// use alpha, because isHidden makes stack views re-layout
sourceView.alpha = 0
toVC.view.alpha = 0
toVC.largeImageController?.contentView.isHidden = true
toVC.largeImageController?.setControlsVisible(false, animated: false)
var finalFrameSize = finalVCFrame.inset(by: fromVC.view.safeAreaInsets).size
let newWidth = finalFrameSize.width / image.size.width
@ -81,21 +94,16 @@ class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTra
imageView.layer.maskedCorners = sourceView.layer.maskedCorners
imageView.layer.masksToBounds = true
let blackView = UIView(frame: finalVCFrame)
blackView.backgroundColor = .black
blackView.alpha = 0
containerView.addSubview(blackView)
containerView.addSubview(imageView)
toVC.view.isHidden = true
let duration = transitionDuration(using: transitionContext)
UIView.animate(withDuration: duration, animations: {
let velocity = 1 / CGFloat(duration)
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.75, initialSpringVelocity: velocity, options: []) {
imageView.frame = finalFrame
imageView.layer.cornerRadius = 0
blackView.alpha = 1
}, completion: { _ in
toVC.view.alpha = 1
toVC.largeImageController?.setControlsVisible(true, animated: false)
} completion: { (_) in
// This shouldn't be necessary. I believe it's a workaround for using a XIB
// for the large image VC. Without this, the final frame of the large image VC
// is not set to the propper rect (it uses the frame of the preview device
@ -103,15 +111,30 @@ class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTra
// (or UIKit does layout differently when loading the view) and this is not necessary.
toVC.view.frame = finalVCFrame
toVC.view.isHidden = false
toVC.largeImageController?.contentView.isHidden = false
fromVC.view.isHidden = false
blackView.removeFromSuperview()
imageView.removeFromSuperview()
sourceView.alpha = 1
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
}
func animateCrossFadeTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let toVC = transitionContext.viewController(forKey: .to) as? LargeImageAnimatableViewController else {
return
}
transitionContext.containerView.addSubview(toVC.view)
toVC.view.alpha = 0
let duration = transitionDuration(using: transitionContext)
UIView.animate(withDuration: duration) {
toVC.view.alpha = 1
} completion: { (_) in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
}
}

View File

@ -27,6 +27,11 @@ class LargeImageShrinkAnimationController: NSObject, UIViewControllerAnimatedTra
return
}
if UIAccessibility.prefersCrossFadeTransitionsBackwardsCompat && !transitionContext.isInteractive {
animateCrossFadeTransition(using: transitionContext)
return
}
guard let sourceView = fromVC.animationSourceView,
let sourceFrame = fromVC.sourceViewFrame(in: toVC.view),
let image = fromVC.animationImage else {
@ -86,4 +91,21 @@ class LargeImageShrinkAnimationController: NSObject, UIViewControllerAnimatedTra
})
}
func animateCrossFadeTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let fromVC = transitionContext.viewController(forKey: .from) as? LargeImageAnimatableViewController,
let toVC = transitionContext.viewController(forKey: .to) else {
return
}
transitionContext.containerView.addSubview(toVC.view)
transitionContext.containerView.addSubview(fromVC.view)
let duration = transitionDuration(using: transitionContext)
UIView.animate(withDuration: duration) {
fromVC.view.alpha = 0
} completion: { (_) in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
}
}

View File

@ -0,0 +1,383 @@
//
// MainSidebarViewController.swift
// Tusker
//
// Created by Shadowfacts on 6/24/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
#if SDK_IOS_14
@available(iOS 14.0, *)
protocol MainSidebarViewControllerDelegate: class {
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item)
}
@available(iOS 14.0, *)
class MainSidebarViewController: UIViewController {
private weak var mastodonController: MastodonController!
weak var sidebarDelegate: MainSidebarViewControllerDelegate?
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
var allItems: [Item] {
[
.tab(.timelines),
.tab(.notifications),
.tab(.myProfile),
] + exploreTabItems
}
var exploreTabItems: [Item] {
var items: [Item] = [.search, .bookmarks]
let snapshot = dataSource.snapshot()
for case let .list(list) in snapshot.itemIdentifiers(inSection: .lists) {
items.append(.list(list))
}
for case let .savedHashtag(hashtag) in snapshot.itemIdentifiers(inSection: .savedHashtags) {
items.append(.savedHashtag(hashtag))
}
for case let .savedInstance(instance) in snapshot.itemIdentifiers(inSection: .savedInstances) {
items.append(.savedInstance(instance))
}
return items
}
private(set) var previouslySelectedItem: Item?
var selectedItem: Item? {
guard let indexPath = collectionView.indexPathsForSelectedItems?.first else {
return nil
}
return dataSource.itemIdentifier(for: indexPath)
}
private(set) var itemLastSelectedTimestamps = [Item: Date]()
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.title = "Tusker"
navigationItem.largeTitleDisplayMode = .always
navigationController!.navigationBar.prefersLargeTitles = true
let layout = UICollectionViewCompositionalLayout.list(using: .init(appearance: .sidebar))
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.backgroundColor = .systemGroupedBackground
collectionView.delegate = self
view.addSubview(collectionView)
dataSource = createDataSource()
applyInitialSnapshot()
select(item: .tab(.timelines), animated: false)
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedHashtags), name: .savedHashtagsChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil)
}
func select(item: Item, animated: Bool) {
guard let indexPath = dataSource.indexPath(for: item) else { return }
collectionView.selectItem(at: indexPath, animated: animated, scrollPosition: .top)
itemLastSelectedTimestamps[item] = Date()
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let listCell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { (cell, indexPath, item) in
var config = cell.defaultContentConfiguration()
config.text = item.title
if let imageName = item.imageName {
config.image = UIImage(systemName: imageName)
}
cell.contentConfiguration = config
}
let outlineHeaderCell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { (cell, indexPath, item) in
var config = cell.defaultContentConfiguration()
config.attributedText = NSAttributedString(string: item.title, attributes: [
.font: UIFont.boldSystemFont(ofSize: 21)
])
cell.contentConfiguration = config
cell.accessories = [.outlineDisclosure(options: .init(style: .header))]
}
return UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView, cellProvider: { (collectionView, indexPath, item) -> UICollectionViewCell? in
if item.hasChildren {
return collectionView.dequeueConfiguredReusableCell(using: outlineHeaderCell, for: indexPath, item: item)
} else {
return collectionView.dequeueConfiguredReusableCell(using: listCell, for: indexPath, item: item)
}
})
}
private func applyInitialSnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections(Section.allCases)
snapshot.appendItems([
.tab(.timelines),
.tab(.notifications),
.search,
.bookmarks,
.tab(.myProfile)
], toSection: .tabs)
snapshot.appendItems([
.tab(.compose)
], toSection: .compose)
dataSource.apply(snapshot, animatingDifferences: false)
reloadLists()
reloadSavedHashtags()
reloadSavedInstances()
}
private func reloadLists() {
let request = Client.getLists()
mastodonController.run(request) { [weak self] (response) in
guard let self = self, case let .success(lists, _) = response else { return }
var exploreSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
exploreSnapshot.append([.listsHeader])
exploreSnapshot.expand([.listsHeader])
exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader)
exploreSnapshot.append([.addList], to: .listsHeader)
DispatchQueue.main.async {
self.dataSource.apply(exploreSnapshot, to: .lists)
}
}
}
@objc private func reloadSavedHashtags() {
var hashtagsSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
hashtagsSnapshot.append([.savedHashtagsHeader])
hashtagsSnapshot.expand([.savedHashtagsHeader])
let sortedHashtags = SavedDataManager.shared.sortedHashtags(for: mastodonController.accountInfo!)
hashtagsSnapshot.append(sortedHashtags.map { .savedHashtag($0) }, to: .savedHashtagsHeader)
hashtagsSnapshot.append([.addSavedHashtag], to: .savedHashtagsHeader)
self.dataSource.apply(hashtagsSnapshot, to: .savedHashtags, animatingDifferences: false)
}
@objc private func reloadSavedInstances() {
var instancesSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
instancesSnapshot.append([.savedInstancesHeader])
instancesSnapshot.expand([.savedInstancesHeader])
let sortedInstances = SavedDataManager.shared.savedInstances(for: mastodonController.accountInfo!)
instancesSnapshot.append(sortedInstances.map { .savedInstance($0) }, to: .savedInstancesHeader)
instancesSnapshot.append([.addSavedInstance], to: .savedInstancesHeader)
self.dataSource.apply(instancesSnapshot, to: .savedInstances, animatingDifferences: false)
}
// todo: deduplicate with ExploreViewController
private func showAddList() {
let alert = UIAlertController(title: "New List", message: "Choose a title for your new list", preferredStyle: .alert)
alert.addTextField(configurationHandler: nil)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
alert.addAction(UIAlertAction(title: "Create List", style: .default, handler: { (_) in
guard let title = alert.textFields?.first?.text else {
fatalError()
}
let request = Client.createList(title: title)
self.mastodonController.run(request) { (response) in
guard case let .success(list, _) = response else { fatalError() }
self.reloadLists()
DispatchQueue.main.async {
self.sidebarDelegate?.sidebar(self, didSelectItem: .list(list))
}
}
}))
present(alert, animated: true)
}
// todo: deduplicate with ExploreViewController
private func showAddSavedHashtag() {
let navController = EnhancedNavigationViewController(rootViewController: AddSavedHashtagViewController(mastodonController: mastodonController))
present(navController, animated: true)
}
// todo: deduplicate with ExploreViewController
private func showAddSavedInstance() {
let findController = FindInstanceViewController(parentMastodonController: mastodonController)
findController.instanceTimelineDelegate = self
let navController = EnhancedNavigationViewController(rootViewController: findController)
present(navController, animated: true)
}
}
@available(iOS 14.0, *)
extension MainSidebarViewController {
enum Section: Int, Hashable, CaseIterable {
case tabs
case compose
case lists
case savedHashtags
case savedInstances
}
enum Item: Hashable {
case tab(MainTabBarViewController.Tab)
case search, bookmarks
case listsHeader, list(List), addList
case savedHashtagsHeader, savedHashtag(Hashtag), addSavedHashtag
case savedInstancesHeader, savedInstance(URL), addSavedInstance
var title: String {
switch self {
case let .tab(tab):
return tab.title
case .search:
return "Search"
case .bookmarks:
return "Bookmarks"
case .listsHeader:
return "Lists"
case let .list(list):
return list.title
case .addList:
return "New List..."
case .savedHashtagsHeader:
return "Saved Hashtags"
case let .savedHashtag(hashtag):
return hashtag.name
case .addSavedHashtag:
return "Save Hashtag..."
case .savedInstancesHeader:
return "Saved Instances"
case let .savedInstance(url):
return url.host!
case .addSavedInstance:
return "Find An Instance..."
}
}
var imageName: String? {
switch self {
case let .tab(tab):
return tab.imageName
case .search:
return "magnifyingglass"
case .bookmarks:
return "bookmark"
case .list(_):
return "list.bullet"
case .savedHashtag(_):
return "number"
case .savedInstance(_):
return "globe"
case .listsHeader, .savedHashtagsHeader, .savedInstancesHeader:
return nil
case .addList, .addSavedHashtag, .addSavedInstance:
return "plus"
}
}
var hasChildren: Bool {
switch self {
case .listsHeader, .savedHashtagsHeader, .savedInstancesHeader:
return true
default:
return false
}
}
}
}
fileprivate extension MainTabBarViewController.Tab {
var title: String {
switch self {
case .timelines:
return "Home"
case .notifications:
return "Notifications"
case .compose:
return "Compose"
case .explore:
return "Explore"
case .myProfile:
return "My Profile"
}
}
var imageName: String? {
switch self {
case .timelines:
return "house"
case .notifications:
return "bell"
case .compose:
return "pencil"
case .explore:
return "magnifyingglass"
case .myProfile:
// todo: use user avatar image
return "person"
}
}
}
@available(iOS 14.0, *)
extension MainSidebarViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
previouslySelectedItem = selectedItem
return true
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else {
collectionView.deselectItem(at: indexPath, animated: true)
return
}
itemLastSelectedTimestamps[item] = Date()
if [MainSidebarViewController.Item.tab(.compose), .addList, .addSavedHashtag, .addSavedInstance].contains(item) {
if let previous = previouslySelectedItem, let indexPath = dataSource.indexPath(for: previous) {
collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredVertically)
}
switch item {
case .tab(.compose):
sidebarDelegate?.sidebarRequestPresentCompose(self)
case .addList:
showAddList()
case .addSavedHashtag:
showAddSavedHashtag()
case .addSavedInstance:
showAddSavedInstance()
default:
fatalError("unreachable")
}
} else {
sidebarDelegate?.sidebar(self, didSelectItem: item)
}
}
}
@available(iOS 14.0, *)
extension MainSidebarViewController: InstanceTimelineViewControllerDelegate {
func didSaveInstance(url: URL) {
dismiss(animated: true) {
self.sidebarDelegate?.sidebar(self, didSelectItem: .savedInstance(url))
}
}
func didUnsaveInstance(url: URL) {
dismiss(animated: true)
}
}
#endif

View File

@ -0,0 +1,326 @@
//
// MainSplitViewController.swift
// Tusker
//
// Created by Shadowfacts on 6/23/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
#if SDK_IOS_14
@available(iOS 14.0, *)
class MainSplitViewController: UISplitViewController {
weak var mastodonController: MastodonController!
private var sidebar: MainSidebarViewController!
// Keep track of navigation stacks per-item so that we can only ever use a single navigation controller
private var navigationStacks: [MainSidebarViewController.Item: [UIViewController]] = [:]
private var tabBarViewController: MainTabBarViewController!
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
super.init(style: .doubleColumn)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
preferredDisplayMode = .oneBesideSecondary
preferredSplitBehavior = .tile
presentsWithGesture = false
showsSecondaryOnlyButton = false
delegate = self
sidebar = MainSidebarViewController(mastodonController: mastodonController)
sidebar.sidebarDelegate = self
setViewController(sidebar, for: .primary)
setViewController(EnhancedNavigationViewController(), for: .secondary)
select(item: .tab(.timelines))
tabBarViewController = MainTabBarViewController(mastodonController: mastodonController)
setViewController(tabBarViewController, for: .compact)
}
func select(item: MainSidebarViewController.Item) {
let nav = viewController(for: .secondary) as! UINavigationController
nav.viewControllers = getOrCreateNavigationStack(item: item)
}
func getOrCreateNavigationStack(item: MainSidebarViewController.Item) -> [UIViewController] {
if let existing = navigationStacks[item], existing.count > 0 {
return existing
} else {
let new = [item.createRootViewController(mastodonController)!]
navigationStacks[item] = new
return new
}
}
}
@available(iOS 14.0, *)
extension MainSplitViewController: UISplitViewControllerDelegate {
/// Transfer the navigation stack for a sidebar item to a destination navgiation controller.
/// - Parameter dropFirst: Remove the first view controller from the item's navigation stack before transferring.
/// - Parameter append: Append the item's navigation stack to the destination nav controller's instead of replacing it.
private func transferNavigationStack(from item: MainSidebarViewController.Item, to destination: UINavigationController, dropFirst: Bool = false, append: Bool = false) {
var itemNavStack: [UIViewController]
if item == sidebar.selectedItem {
let detailNav = viewController(for: .secondary) as! UINavigationController
itemNavStack = detailNav.viewControllers
} else {
itemNavStack = navigationStacks[item] ?? []
navigationStacks.removeValue(forKey: item)
}
if itemNavStack.isEmpty {
itemNavStack = [item.createRootViewController(mastodonController)!]
}
if dropFirst {
itemNavStack.remove(at: 0)
}
if append {
destination.viewControllers += itemNavStack
} else {
destination.viewControllers = itemNavStack
}
}
func splitViewControllerDidCollapse(_ svc: UISplitViewController) {
// on iPhones, the sidebar VC is never loaded, but since this method is still called, we can't do anything
guard sidebar.isViewLoaded else { return }
// Transfer the nav stacks for all the sidebar items that map 1 <-> 1 with tabs
for tab in [MainTabBarViewController.Tab.timelines, .notifications, .myProfile] {
let tabNav = tabBarViewController.viewController(for: tab) as! UINavigationController
transferNavigationStack(from: .tab(tab), to: tabNav)
}
// Since several sidebar items map to the single Explore tab, we only transfer the
// navigation stack of the most-recently used one.
let mostRecentExploreItem: (MainSidebarViewController.Item, Date)? =
sidebar.exploreTabItems.compactMap {
if let timestamp = sidebar.itemLastSelectedTimestamps[$0] {
return ($0, timestamp)
} else {
return nil
}
}.min {
$0.1 > $1.1
}
if let mostRecentExploreItem = mostRecentExploreItem?.0,
mostRecentExploreItem != .search {
let exploreNav = tabBarViewController.viewController(for: .explore) as! UINavigationController
// Pop back to root, so we're appending to the Explore VC instead of some other VC
exploreNav.popToRootViewController(animated: false)
// Append so we don't replace the Explore VC
transferNavigationStack(from: mostRecentExploreItem, to: exploreNav, append: true)
}
// Switch the tab bar to focus the same item as the sidebar has selected
switch sidebar.selectedItem! {
case let .tab(tab):
// sidebar items that map 1 <-> 1 can be transferred directly
tabBarViewController.select(tab: tab)
case .search:
// Search sidebar item maps to the Explore tab with the search controller/results visible
// The nav stack can't be copied directly, since the split VC uses a different SearchViewController
// so that explore items aren't shown multiple times.
let exploreNav = tabBarViewController.viewController(for: .explore) as! UINavigationController
let explore: ExploreViewController
if let existing = exploreNav.viewControllers.first as? ExploreViewController {
explore = existing
exploreNav.popToRootViewController(animated: false)
} else {
// If the Explore tab hasn't been loaded before, it's root view controller won't be loaded yet, so create and add it manually.
explore = ExploreViewController(mastodonController: mastodonController)
exploreNav.viewControllers = [explore]
}
// Make sure viewDidLoad is called so that the searchController/resultsController have been initialized
explore.loadViewIfNeeded()
let nav = viewController(for: .secondary) as! UINavigationController
let search = nav.viewControllers.first as! SearchViewController
// Copy the search query from the search VC to the Explore VC's search controller.
let query = search.searchController.searchBar.text ?? ""
explore.searchController.searchBar.text = query
// Instruct the explore controller to show its search controller immediately upon its first appearance.
// explore.searchController.isActive can't be set directly, see FB7814561
explore.searchControllerStatusOnAppearance = !query.isEmpty
// Copy the results from the search VC's results controller to avoid the delay introduced by an extra network request
explore.resultsController.loadResults(from: search.resultsController)
// Transfer the navigation stack, dropping the search VC, to keep anything the user has opened
transferNavigationStack(from: .search, to: exploreNav, dropFirst: true, append: true)
tabBarViewController.select(tab: .explore)
case .bookmarks, .list(_), .savedHashtag(_), .savedInstance(_):
tabBarViewController.select(tab: .explore)
// Make sure the Explore VC doesn't show it's search bar when it appears, in case the user was previously
// in compact mode and performing a search.
let exploreNav = tabBarViewController.viewController(for: .explore) as! UINavigationController
let explore = exploreNav.viewControllers.first as! ExploreViewController
explore.searchControllerStatusOnAppearance = false
case .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance:
// These items are not selectable in the sidebar collection view, so this code is unreachable.
fatalError("unreachable")
}
}
/// Transfer a navigation stack from a navigation controller belonging to the tab bar VC to a sidebar item.
/// - Parameter skipFirst:The number of view controllers that should be skipped from the source navigation controller.
/// - Parameter prepend: An optional view controller to prepend to the beginning of the navigation stack being moved.
private func transferNavigationStack(from navController: UINavigationController, to item: MainSidebarViewController.Item, skipFirst: Int = 0, prepend: UIViewController? = nil) {
let viewControllersToMove = navController.viewControllers.dropFirst(skipFirst)
navController.viewControllers.removeLast(navController.viewControllers.count - skipFirst)
if let prepend = prepend {
navigationStacks[item] = [prepend] + viewControllersToMove
} else {
navigationStacks[item] = Array(viewControllersToMove)
}
}
func splitViewControllerDidExpand(_ svc: UISplitViewController) {
// For each sidebar item, transfer the existing navigation stasck from the tab bar controller to ourself.
var exploreItem: MainSidebarViewController.Item?
for tab in MainTabBarViewController.Tab.allCases {
guard let tabNavController = tabBarViewController.viewController(for: tab) as? UINavigationController else { continue }
let tabNavigationStack = tabNavController.viewControllers
switch tab {
case .timelines, .notifications, .myProfile:
// Items that map 1 <-> 1 to tabs can be transferred directly.
let item = MainSidebarViewController.Item.tab(tab)
transferNavigationStack(from: tabNavController, to: item)
case .explore:
// The Explore tab is more complicated since it encapsulates a bunch of screens which have top-level sidebar items.
var toPrepend: UIViewController? = nil
// If the tab navigation stack has only one item or the search controller is active, it corresponds to the Search item
// For other items, the 2nd VC in the nav stack determines which sidebar item they map to.
// Search screen has special considerations, all others can be transferred directly.
if tabNavigationStack.count == 1 || ((tabNavigationStack.first as? ExploreViewController)?.searchController?.isActive ?? false) {
exploreItem = .search
let searchVC = SearchViewController(mastodonController: mastodonController)
searchVC.loadViewIfNeeded()
let explore = tabNavigationStack.first as! ExploreViewController
if let exploreSearchControler = explore.searchController,
let query = exploreSearchControler.searchBar.text {
// Transfer query to search VC
searchVC.searchController.searchBar.text = query
// If there is a query, make the search VC activate itself upon appearing
searchVC.searchControllerStatusOnAppearance = !query.isEmpty
// Transfer the results from the explore VC, to avoid an extra network request
searchVC.resultsController.loadResults(from: explore.resultsController)
}
// Insert the new search VC at the beginning of the new search nav stack
toPrepend = searchVC
} else if tabNavigationStack[1] is BookmarksTableViewController {
exploreItem = .bookmarks
} else if let listVC = tabNavigationStack[1] as? ListTimelineViewController {
exploreItem = .list(listVC.list)
} else if let hashtagVC = tabNavigationStack[1] as? HashtagTimelineViewController {
exploreItem = .savedHashtag(hashtagVC.hashtag)
} else if let instanceVC = tabNavigationStack[1] as? InstanceTimelineViewController {
exploreItem = .savedInstance(instanceVC.instanceURL)
}
transferNavigationStack(from: tabNavController, to: exploreItem!, skipFirst: 1, prepend: toPrepend)
case .compose:
// The compose tab can't be activated, this is unreachable.
fatalError("unreachable")
}
}
// Transfer the selected tab from the tab bar VC to the sidebar
switch tabBarViewController.selectedTab {
case .timelines, .notifications, .myProfile:
// These tabs map 1 <-> 1 with sidebar items
let item = MainSidebarViewController.Item.tab(tabBarViewController.selectedTab)
sidebar.select(item: item, animated: false)
select(item: item)
case .explore:
// If the explore tab is active, the sidebar item is determined above when transferring the explore VC's nav stack
sidebar.select(item: exploreItem!, animated: false)
select(item: exploreItem!)
default:
return
}
}
}
@available(iOS 14.0, *)
extension MainSplitViewController: MainSidebarViewControllerDelegate {
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController) {
presentCompose()
}
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) {
let nav = viewController(for: .secondary) as! UINavigationController
if let previous = sidebar.previouslySelectedItem {
navigationStacks[previous] = nav.viewControllers
}
select(item: item)
}
}
@available(iOS 14.0, *)
fileprivate extension MainSidebarViewController.Item {
func createRootViewController(_ mastodonController: MastodonController) -> UIViewController? {
switch self {
case let .tab(tab):
return tab.createViewController(mastodonController)
case .search:
return SearchViewController(mastodonController: mastodonController)
case .bookmarks:
return BookmarksTableViewController(mastodonController: mastodonController)
case let .list(list):
return ListTimelineViewController(for: list, mastodonController: mastodonController)
case let .savedHashtag(hashtag):
return HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController)
case let .savedInstance(url):
return InstanceTimelineViewController(for: url, parentMastodonController: mastodonController)
default:
return nil
}
}
}
@available(iOS 14.0, *)
extension MainSplitViewController: TuskerRootViewController {
func presentCompose() {
let vc = ComposeHostingController(mastodonController: mastodonController)
let nav = EnhancedNavigationViewController(rootViewController: vc)
nav.presentationController?.delegate = vc
present(nav, animated: true)
}
func select(tab: MainTabBarViewController.Tab) {
if tab == .compose {
presentCompose()
} else {
select(item: .tab(tab))
}
}
}
#endif

View File

@ -7,11 +7,18 @@
//
import UIKit
import SwiftUI
class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
weak var mastodonController: MastodonController!
private var composePlaceholder: UIViewController!
var selectedTab: Tab {
return Tab(rawValue: selectedIndex)!
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
if UIDevice.current.userInterfaceIdiom == .phone {
return .portrait
@ -35,12 +42,16 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
self.delegate = self
composePlaceholder = UIViewController()
composePlaceholder.title = "Compose"
composePlaceholder.tabBarItem.image = UIImage(systemName: "pencil")
viewControllers = [
embedInNavigationController(TimelinesPageViewController(mastodonController: mastodonController)),
embedInNavigationController(NotificationsPageViewController(mastodonController: mastodonController)),
ComposeViewController(mastodonController: mastodonController),
embedInNavigationController(ExploreViewController(mastodonController: mastodonController)),
embedInNavigationController(MyProfileTableViewController(mastodonController: mastodonController)),
embedInNavigationController(Tab.timelines.createViewController(mastodonController)),
embedInNavigationController(Tab.notifications.createViewController(mastodonController)),
composePlaceholder,
embedInNavigationController(Tab.explore.createViewController(mastodonController)),
embedInNavigationController(Tab.myProfile.createViewController(mastodonController)),
]
}
@ -53,35 +64,50 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
}
func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
if viewController is ComposeViewController {
if viewController == composePlaceholder {
presentCompose()
return false
}
if viewController == viewControllers![selectedIndex],
let nav = viewController as? UINavigationController,
nav.viewControllers.count == 1,
let scrollableVC = nav.viewControllers.first as? TabBarScrollableViewController {
scrollableVC.tabBarScrollToTop()
return false
}
return true
}
func presentCompose() {
let compose = ComposeViewController(mastodonController: mastodonController)
let navigationController = embedInNavigationController(compose)
navigationController.presentationController?.delegate = compose
present(navigationController, animated: true)
func setViewController(_ viewController: UIViewController, forTab tab: Tab) {
viewControllers![tab.rawValue] = viewController
}
func viewController(for tab: Tab) -> UIViewController {
return viewControllers![tab.rawValue]
}
}
extension MainTabBarViewController {
enum Tab: Int {
enum Tab: Int, Hashable, CaseIterable {
case timelines
case notifications
case compose
case explore
case myProfile
}
func select(tab: Tab) {
if tab == .compose {
presentCompose()
} else {
selectedIndex = tab.rawValue
func createViewController(_ mastodonController: MastodonController) -> UIViewController {
switch self {
case .timelines:
return TimelinesPageViewController(mastodonController: mastodonController)
case .notifications:
return NotificationsPageViewController(mastodonController: mastodonController)
case .compose:
return ComposeHostingController(mastodonController: mastodonController)
case .explore:
return ExploreViewController(mastodonController: mastodonController)
case .myProfile:
return MyProfileViewController(mastodonController: mastodonController)
}
}
}
@ -93,3 +119,20 @@ extension MainTabBarViewController {
}
}
}
extension MainTabBarViewController: TuskerRootViewController {
func presentCompose() {
let vc = ComposeHostingController(mastodonController: mastodonController)
let nav = EnhancedNavigationViewController(rootViewController: vc)
nav.presentationController?.delegate = vc
present(nav, animated: true)
}
func select(tab: Tab) {
if tab == .compose {
presentCompose()
} else {
selectedIndex = tab.rawValue
}
}
}

View File

@ -0,0 +1,14 @@
//
// TuskerRootViewController.swift
// Tusker
//
// Created by Shadowfacts on 6/24/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
protocol TuskerRootViewController: UIViewController {
func presentCompose()
func select(tab: MainTabBarViewController.Tab)
}

View File

@ -118,7 +118,9 @@ class InstanceSelectorTableViewController: UITableViewController {
let request = Client.getInstance()
client.run(request) { (response) in
var snapshot = self.dataSource.snapshot()
if snapshot.indexOfSection(.selected) != nil {
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .selected))
}
if case let .success(instance, _) = response {
if !snapshot.sectionIdentifiers.contains(.selected) {

View File

@ -48,19 +48,17 @@ extension OnboardingViewController: InstanceSelectorTableViewControllerDelegate
let mastodonController = MastodonController(instanceURL: instanceURL)
mastodonController.registerApp { (clientID, clientSecret) in
let callbackURL = "tusker://oauth"
var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)!
components.path = "/oauth/authorize"
components.queryItems = [
URLQueryItem(name: "client_id", value: clientID),
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "scope", value: "read write follow"),
URLQueryItem(name: "redirect_uri", value: callbackURL)
URLQueryItem(name: "redirect_uri", value: "tusker://oauth")
]
let authorizeURL = components.url!
self.authenticationSession = ASWebAuthenticationSession(url: authorizeURL, callbackURLScheme: callbackURL) { url, error in
self.authenticationSession = ASWebAuthenticationSession(url: authorizeURL, callbackURLScheme: "tusker") { url, error in
guard error == nil,
let url = url,
let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
@ -84,6 +82,8 @@ extension OnboardingViewController: InstanceSelectorTableViewControllerDelegate
}
}
DispatchQueue.main.async {
// Prefer ephemeral sessions to make it easier to sign into multiple accounts on the same instance.
self.authenticationSession!.prefersEphemeralWebBrowserSession = true
self.authenticationSession!.presentationContextProvider = self
self.authenticationSession!.start()
}

View File

@ -16,7 +16,8 @@ struct AdvancedPrefsView : View {
formattingSection
automationSection
cachingSection
}.listStyle(GroupedListStyle())
}
.insetOrGroupedListStyle()
.navigationBarTitle(Text("Advanced"))
}
@ -36,7 +37,7 @@ struct AdvancedPrefsView : View {
}
var automationSection: some View {
Section(header: Text("AUTOMATION")) {
Section(header: Text("Automation")) {
NavigationLink(destination: SilentActionPrefs()) {
Text("Silent Action Permissions")
}
@ -44,14 +45,17 @@ struct AdvancedPrefsView : View {
}
var cachingSection: some View {
Section(header: Text("CACHING")) {
Section(header: Text("Caching"), footer: Text("Clearing caches will restart the app.")) {
Button(action: clearCache) {
Text("Clear Cache")
Text("Clear Mastodon Cache")
}.foregroundColor(.red)
Button(action: clearImageCaches) {
Text("Clear Image Caches")
}.foregroundColor(.red)
}
}
func clearCache() {
private func clearCache() {
for account in LocalData.shared.accounts {
let controller = MastodonController.getForAccount(account)
let coordinator = controller.persistentContainer.persistentStoreCoordinator
@ -59,7 +63,22 @@ struct AdvancedPrefsView : View {
try! coordinator.destroyPersistentStore(at: store.url!, ofType: store.type, options: store.options)
}
}
MastodonController.resetAll()
resetUI()
}
private func clearImageCaches() {
[
ImageCache.avatars,
ImageCache.headers,
ImageCache.attachments,
ImageCache.emojis,
].forEach {
try! $0.reset()
}
resetUI()
}
private func resetUI() {
let mostRecent = LocalData.shared.getMostRecentAccount()!
NotificationCenter.default.post(name: .activateAccount, object: nil, userInfo: ["account": mostRecent])
}

View File

@ -30,15 +30,26 @@ struct AppearancePrefsView : View {
Text("Light").tag(UIUserInterfaceStyle.light)
Text("Dark").tag(UIUserInterfaceStyle.dark)
}
Toggle(isOn: $preferences.showRepliesInProfiles) {
Text("Show Replies in Profiles")
accountsSection
postsSection
}
.insetOrGroupedListStyle()
.navigationBarTitle(Text("Appearance"))
}
private var accountsSection: some View {
Section(header: Text("Accounts")) {
Toggle(isOn: useCircularAvatars) {
Text("Use Circular Avatars")
}
Toggle(isOn: $preferences.hideCustomEmojiInUsernames) {
Text("Hide Custom Emoji in Usernames")
}
}
}
private var postsSection: some View {
Section(header: Text("Posts")) {
Toggle(isOn: $preferences.showIsStatusReplyIcon) {
Text("Show Status Reply Icons")
}
@ -46,8 +57,6 @@ struct AppearancePrefsView : View {
Text("Always Show Status Visibility Icons")
}
}
.listStyle(GroupedListStyle())
.navigationBarTitle(Text("Appearance"))
}
}

View File

@ -14,11 +14,14 @@ struct BehaviorPrefsView: View {
var body: some View {
List {
linksSection
}.listStyle(GroupedListStyle()).navigationBarTitle(Text("Behavior"))
contentWarningsSection
}
.insetOrGroupedListStyle()
.navigationBarTitle(Text("Behavior"))
}
var linksSection: some View {
Section(header: Text("LINKS")) {
Section(header: Text("Links")) {
Toggle(isOn: $preferences.openLinksInApps) {
Text("Open Links in Apps")
}
@ -30,6 +33,18 @@ struct BehaviorPrefsView: View {
}.disabled(!preferences.useInAppSafari)
}
}
var contentWarningsSection: some View {
Section(header: Text("Content Warnings")) {
Toggle(isOn: $preferences.expandAllContentWarnings) {
Text("Expand All Content Warnings")
}
Toggle(isOn: $preferences.collapseLongPosts) {
Text("Collapse Long Posts")
}
}
}
}
#if DEBUG

View File

@ -16,11 +16,13 @@ struct ComposingPrefsView: View {
List {
composingSection
replyingSection
}.listStyle(GroupedListStyle()).navigationBarTitle("Composing")
}
.insetOrGroupedListStyle()
.navigationBarTitle("Composing")
}
var composingSection: some View {
Section(header: Text("COMPOSING")) {
Section(header: Text("Composing")) {
Picker(selection: $preferences.defaultPostVisibility, label: Text("Default Post Visibility")) {
ForEach(Status.Visibility.allCases, id: \.self) { visibility in
HStack {
@ -41,7 +43,7 @@ struct ComposingPrefsView: View {
}
var replyingSection: some View {
Section(header: Text("REPLYING")) {
Section(header: Text("Replying")) {
Picker(selection: $preferences.contentWarningCopyMode, label: Text("Copy Content Warnings")) {
Text("As-is").tag(ContentWarningCopyMode.asIs)
Text("Prepend 're: '").tag(ContentWarningCopyMode.prependRe)

View File

@ -14,11 +14,13 @@ struct MediaPrefsView: View {
var body: some View {
List {
viewingSection
}.listStyle(GroupedListStyle()).navigationBarTitle("Media")
}
.insetOrGroupedListStyle()
.navigationBarTitle("Media")
}
var viewingSection: some View {
Section(header: Text("VIEWING")) {
Section(header: Text("Viewing")) {
Toggle(isOn: $preferences.blurAllMedia) {
Text("Blur All Media")
}

View File

@ -15,7 +15,7 @@ struct PreferencesView: View {
// workaround: the navigation view is provided by MyProfileTableViewController so that it can inject the Done button
// NavigationView {
List {
Section {
Section(header: Text("Accounts")) {
ForEach(localData.accounts, id: \.accessToken) { (account) in
Button(action: {
NotificationCenter.default.post(name: .activateAccount, object: nil, userInfo: ["account": account])
@ -75,12 +75,8 @@ struct PreferencesView: View {
}
}
}
.listStyle(GroupedListStyle())
.insetOrGroupedListStyle()
.navigationBarTitle(Text("Preferences"), displayMode: .inline)
.onDisappear {
// todo: this onDisappear callback is not called in beta 4, check again in beta 5
NotificationCenter.default.post(name: .preferencesChanged, object: nil)
}
// }
}
@ -89,6 +85,17 @@ struct PreferencesView: View {
}
}
extension View {
@ViewBuilder
func insetOrGroupedListStyle() -> some View {
if #available(iOS 14.0, *) {
self.listStyle(InsetGroupedListStyle())
} else {
self.listStyle(GroupedListStyle())
}
}
}
#if DEBUG
struct PreferencesView_Previews : PreviewProvider {
static var previews: some View {

View File

@ -14,7 +14,7 @@ struct SilentActionPrefs : View {
List(Array(preferences.silentActions.keys), id: \.self) { source in
SilentActionPermissionCell(source: source)
}
.listStyle(GroupedListStyle())
.insetOrGroupedListStyle()
// .navigationBarTitle("Silent Action Permissions")
// see FB6838291
}

View File

@ -15,7 +15,8 @@ struct WellnessPrefsView: View {
List {
showFavAndReblogCountSection
notificationsModeSection
}.listStyle(GroupedListStyle())
}
.insetOrGroupedListStyle()
.navigationBarTitle(Text("Digital Wellness"))
}

View File

@ -1,5 +1,5 @@
//
// MyProfileTableViewController.swift
// MyProfileViewController.swift
// Tusker
//
// Created by Shadowfacts on 11/24/18.
@ -7,9 +7,8 @@
//
import UIKit
import SwiftUI
class MyProfileTableViewController: ProfileTableViewController {
class MyProfileViewController: ProfileViewController {
init(mastodonController: MastodonController) {
super.init(accountID: nil, mastodonController: mastodonController)
@ -17,9 +16,10 @@ class MyProfileTableViewController: ProfileTableViewController {
title = "My Profile"
tabBarItem.image = UIImage(systemName: "person.fill")
mastodonController.getOwnAccount { (account) in
DispatchQueue.main.async {
self.accountID = account.id
}
_ = ImageCache.avatars.get(account.avatar, completion: { [weak self] (data) in
guard let self = self, let data = data, let image = UIImage(data: data) else { return }
@ -33,28 +33,20 @@ class MyProfileTableViewController: ProfileTableViewController {
}
})
}
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Preferences", style: .plain, target: self, action: #selector(preferencesPressed))
}
required init?(coder aDecoder: NSCoder) {
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Preferences", style: .plain, target: self, action: #selector(preferencesPressed))
}
@objc func preferencesPressed() {
present(PreferencesNavigationController(mastodonController: mastodonController), animated: true)
}
@objc func closePreferences() {
dismiss(animated: true)
}
}

View File

@ -0,0 +1,321 @@
//
// ProfileStatusesViewController.swift
// Tusker
//
// Created by Shadowfacts on 7/3/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class ProfileStatusesViewController: EnhancedTableViewController {
weak var mastodonController: MastodonController!
private(set) var headerView: ProfileHeaderView!
var accountID: String!
let kind: Kind
private var pinnedStatuses: [(id: String, state: StatusState)] = []
private var timelineSegments: [[(id: String, state: StatusState)]] = []
private var older: RequestRange?
private var newer: RequestRange?
var loaded = false
init(accountID: String?, kind: Kind, mastodonController: MastodonController) {
self.accountID = accountID
self.kind = kind
self.mastodonController = mastodonController
super.init(style: .plain)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
refreshControl = UIRefreshControl()
refreshControl!.addTarget(self, action: #selector(refreshStatuses(_:)), for: .valueChanged)
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 140
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: "statusCell")
tableView.prefetchDataSource = self
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if !loaded,
let accountID = accountID,
let account = mastodonController.persistentContainer.account(for: accountID) {
updateUI(account: account)
}
}
func updateUI(account: AccountMO) {
guard !loaded else { return }
loaded = true
if kind == .statuses {
getPinnedStatuses { (response) in
guard case let .success(statuses, _) = response else {
// todo: error message
return
}
if statuses.isEmpty { return }
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
self.pinnedStatuses = statuses.map { ($0.id, .unknown) }
let indexPaths = (0..<statuses.count).map { IndexPath(row: $0, section: 0) }
DispatchQueue.main.async {
UIView.performWithoutAnimation {
self.tableView.insertRows(at: indexPaths, with: .none)
}
}
}
}
}
getStatuses { (response) in
guard case let .success(statuses, pagination) = response else {
// todo: error message
return
}
if statuses.isEmpty { return }
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
self.timelineSegments.append(statuses.map { ($0.id, .unknown) })
self.older = pagination?.older
self.newer = pagination?.newer
DispatchQueue.main.async {
UIView.performWithoutAnimation {
self.tableView.insertSections(IndexSet(integer: 1), with: .none)
}
}
}
}
}
private func getStatuses(for range: RequestRange = .default, completion: @escaping Client.Callback<[Status]>) {
let request: Request<[Status]>
switch kind {
case .statuses:
request = Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true)
case .withReplies:
request = Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: false)
case .onlyMedia:
request = Account.getStatuses(accountID, range: range, onlyMedia: true, pinned: false, excludeReplies: false)
}
mastodonController.run(request, completion: completion)
}
private func getPinnedStatuses(completion: @escaping Client.Callback<[Status]>) {
let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false)
mastodonController.run(request, completion: completion)
}
// MARK: Interaction
@objc func refreshStatuses(_ sender: UIRefreshControl) {
guard let newer = newer else { return }
getStatuses(for: newer) { (response) in
guard case let .success(newStatuses, pagination) = response else {
// todo: error message
return
}
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
// if there's no newer request range (because no statuses were returned),
// we don't want to change the current newer pagination, so that we can
// continue to load statuses newer than whatever was last loaded
if let newer = pagination?.newer {
self.newer = newer
}
let indexPaths = (0..<newStatuses.count).map { IndexPath(row: $0, section: 1) }
DispatchQueue.main.async {
self.timelineSegments[0].insert(contentsOf: newStatuses.map { ($0.id, .unknown) }, at: 0)
UIView.performWithoutAnimation {
self.tableView.insertRows(at: indexPaths, with: .none)
}
self.refreshControl!.endRefreshing()
}
}
}
if kind == .statuses {
getPinnedStatuses { (response) in
guard case let .success(newPinnedStatuses, _) = response else {
// todo: error message
return
}
self.mastodonController.persistentContainer.addAll(statuses: newPinnedStatuses) {
let oldPinnedStatuses = self.pinnedStatuses
let pinnedStatuses = newPinnedStatuses.map { (status) -> (id: String, state: StatusState) in
let state: StatusState
if let (_, oldState) = oldPinnedStatuses.first(where: { $0.id == status.id }) {
state = oldState
} else {
state = .unknown
}
return (status.id, state)
}
DispatchQueue.main.async {
self.pinnedStatuses = pinnedStatuses
UIView.performWithoutAnimation {
self.tableView.reloadSections(IndexSet(integer: 0), with: .none)
}
}
}
}
}
}
// MARK: Table view data source
override func numberOfSections(in tableView: UITableView) -> Int {
// 1 for pinned, rest for timeline
return 1 + timelineSegments.count
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if section == 0 {
return pinnedStatuses.count
} else {
return timelineSegments[section - 1].count
}
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell
cell.delegate = self
if indexPath.section == 0 {
cell.showPinned = true
let (id, state) = pinnedStatuses[indexPath.row]
cell.updateUI(statusID: id, state: state)
} else {
cell.showPinned = false
let (id, state) = timelineSegments[indexPath.section - 1][indexPath.row]
cell.updateUI(statusID: id, state: state)
}
return cell
}
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// todo: if scrolling up, remove statuses at bottom like timeline VC
// load older statuses if at bottom
if timelineSegments.count > 0,
indexPath.section == timelineSegments.count,
indexPath.row == timelineSegments[indexPath.section - 1].count - 1 {
guard let older = older else { return }
getStatuses(for: older) { (response) in
guard case let .success(newStatuses, pagination) = response else {
// todo: error message
return
}
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
// if there is no older request range, we want to set ours to nil
// otherwise we would end up loading the same statuses again
self.older = pagination?.older
DispatchQueue.main.async {
let start = self.timelineSegments[indexPath.section - 1].count
let indexPaths = (0..<newStatuses.count).map { IndexPath(row: start + $0, section: indexPath.section) }
self.timelineSegments[indexPath.section - 1].append(contentsOf: newStatuses.map { ($0.id, .unknown) })
UIView.performWithoutAnimation {
self.tableView.insertRows(at: indexPaths, with: .none)
}
}
}
}
}
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
}
override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.leadingSwipeActionsConfiguration()
}
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
}
}
extension ProfileStatusesViewController {
enum Kind {
case statuses, withReplies, onlyMedia
}
}
extension ProfileStatusesViewController: StatusTableViewCellDelegate {
var apiController: MastodonController { mastodonController }
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
// causes the table view to recalculate the cell heights
tableView.beginUpdates()
tableView.endUpdates()
}
}
extension ProfileStatusesViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
let statusID: String
if indexPath.section == 0 {
statusID = pinnedStatuses[indexPath.row].id
} else {
statusID = timelineSegments[indexPath.section - 1][indexPath.row].id
}
guard let status = mastodonController.persistentContainer.status(for: statusID) else { continue }
_ = ImageCache.avatars.get(status.account.avatar, completion: nil)
for attachment in status.attachments where attachment.kind == .image {
_ = ImageCache.attachments.get(attachment.url, completion: nil)
}
}
}
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
let statusID: String
if indexPath.section == 0 {
statusID = pinnedStatuses[indexPath.row].id
} else {
statusID = timelineSegments[indexPath.section - 1][indexPath.row].id
}
guard let status = mastodonController.persistentContainer.status(for: statusID) else { continue }
ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
for attachment in status.attachments where attachment.kind == .image {
ImageCache.avatars.cancelWithoutCallback(attachment.url)
}
}
}
}

View File

@ -1,354 +0,0 @@
//
// ProfileTableViewController.swift
// Tusker
//
// Created by Shadowfacts on 8/27/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import SafariServices
class ProfileTableViewController: EnhancedTableViewController {
weak var mastodonController: MastodonController!
var accountID: String!
var pinnedStatuses: [(id: String, state: StatusState)] = []
var timelineSegments: [[(id: String, state: StatusState)]] = []
var older: RequestRange?
var newer: RequestRange?
private var loadingVC: LoadingViewController? = nil
private var loaded = false
init(accountID: String?, mastodonController: MastodonController) {
self.mastodonController = mastodonController
self.accountID = accountID
super.init(style: .plain)
self.refreshControl = UIRefreshControl()
refreshControl!.addTarget(self, action: #selector(refreshStatuses(_:)), for: .valueChanged)
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composePressed(_:)))
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemeneted")
}
deinit {
if let id = accountID, let container = mastodonController?.persistentContainer {
container.backgroundContext.perform {
container.account(for: id, in: container.backgroundContext)?.decrementReferenceCount()
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 140
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: nil), forCellReuseIdentifier: "statusCell")
tableView.register(UINib(nibName: "ProfileHeaderTableViewCell", bundle: nil), forCellReuseIdentifier: "headerCell")
tableView.prefetchDataSource = self
if accountID == nil {
loadingVC = LoadingViewController()
embedChild(loadingVC!)
}
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if !loaded, let accountID = accountID {
loaded = true
loadingVC?.removeViewAndController()
loadingVC = nil
if mastodonController.persistentContainer.account(for: accountID) != nil {
updateAccountUI()
} else {
loadingVC = LoadingViewController()
embedChild(loadingVC!)
let request = Client.getAccount(id: accountID)
mastodonController.run(request) { (response) in
guard case let .success(account, _) = response else {
let alert = UIAlertController(title: "Something Went Wrong", message: "Couldn't load the selected account", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { (_) in
self.navigationController!.popViewController(animated: true)
}))
DispatchQueue.main.async {
self.present(alert, animated: true)
}
return
}
self.mastodonController.persistentContainer.addOrUpdate(account: account, incrementReferenceCount: true) { (_) in
DispatchQueue.main.async {
self.updateAccountUI()
self.tableView.reloadData()
}
}
}
}
}
}
func updateAccountUI() {
updateUIForPreferences()
getStatuses(onlyPinned: true) { (response) in
guard case let .success(statuses, _) = response else { fatalError() }
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
self.pinnedStatuses = statuses.map { ($0.id, .unknown) }
let indexPaths = (0..<statuses.count).map { IndexPath(row: $0, section: 1) }
DispatchQueue.main.async {
UIView.performWithoutAnimation {
self.tableView.insertRows(at: indexPaths, with: .none)
}
}
}
}
getStatuses() { response in
guard case let .success(statuses, pagination) = response else { fatalError() }
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
self.timelineSegments.append(statuses.map { ($0.id, .unknown) })
self.older = pagination?.older
self.newer = pagination?.newer
DispatchQueue.main.async {
UIView.performWithoutAnimation {
self.tableView.insertSections(IndexSet(integer: 2), with: .none)
}
}
}
}
}
@objc func updateUIForPreferences() {
guard let accountID = accountID, let account = mastodonController.persistentContainer.account(for: accountID) else { return }
navigationItem.title = account.displayNameWithoutCustomEmoji
}
func getStatuses(for range: RequestRange = .default, onlyPinned: Bool = false, completion: @escaping Client.Callback<[Status]>) {
let request = Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: onlyPinned, excludeReplies: !Preferences.shared.showRepliesInProfiles)
mastodonController.run(request, completion: completion)
}
func sendMessageMentioning() {
guard let account = mastodonController.persistentContainer.account(for: accountID) else { fatalError("Missing cached account \(accountID!)") }
let vc = UINavigationController(rootViewController: ComposeViewController(mentioningAcct: account.acct, mastodonController: mastodonController))
present(vc, animated: true)
}
// MARK: - Table view data source
override func numberOfSections(in tableView: UITableView) -> Int {
// 1 section for header, 1 section for pinned, rest for timeline
return 2 + timelineSegments.count
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if section == 0 {
return accountID == nil || mastodonController.persistentContainer.account(for: accountID) == nil ? 0 : 1
} else if section == 1 {
return pinnedStatuses.count
} else {
return timelineSegments[section - 2].count
}
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
switch indexPath.section {
case 0:
guard let cell = tableView.dequeueReusableCell(withIdentifier: "headerCell", for: indexPath) as? ProfileHeaderTableViewCell else { fatalError() }
cell.selectionStyle = .none
cell.delegate = self
cell.updateUI(for: accountID)
return cell
case 1:
guard let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as? TimelineStatusTableViewCell else { fatalError() }
let (id, state) = pinnedStatuses[indexPath.row]
cell.showPinned = true
cell.delegate = self
cell.updateUI(statusID: id, state: state)
return cell
default:
guard let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as? TimelineStatusTableViewCell else { fatalError() }
let (id, state) = timelineSegments[indexPath.section - 2][indexPath.row]
cell.delegate = self
cell.updateUI(statusID: id, state: state)
return cell
}
}
// MARK: - Table view delegate
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// todo: if scrolling up, remove statuses at bottom like timeline VC
// load older statuses if at bottom
if timelineSegments.count > 0 && indexPath.section - 1 == timelineSegments.count && indexPath.row == timelineSegments[indexPath.section - 2].count - 1 {
guard let older = older else { return }
getStatuses(for: older) { response in
guard case let .success(newStatuses, pagination) = response else { fatalError() }
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
self.older = pagination?.older
DispatchQueue.main.async {
let start = self.timelineSegments[indexPath.section - 2].count
let indexPaths = (0..<newStatuses.count).map { IndexPath(row: start + $0, section: indexPath.section) }
self.timelineSegments[indexPath.section - 2].append(contentsOf: newStatuses.map { ($0.id, .unknown) })
UIView.performWithoutAnimation {
self.tableView.insertRows(at: indexPaths, with: .none)
}
}
}
}
}
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
}
override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.leadingSwipeActionsConfiguration()
}
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
}
@objc func refreshStatuses(_ sender: Any) {
guard let newer = newer else { return }
getStatuses(for: newer) { response in
guard case let .success(newStatuses, pagination) = response else { fatalError() }
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
if let newer = pagination?.newer {
self.newer = newer
}
let indexPaths = (0..<newStatuses.count).map { IndexPath(row: $0, section: 2) }
DispatchQueue.main.async {
self.timelineSegments[0].insert(contentsOf: newStatuses.map { ($0.id, .unknown) }, at: 0)
UIView.performWithoutAnimation {
self.tableView.insertRows(at: indexPaths, with: .none)
}
self.refreshControl?.endRefreshing()
}
}
}
getStatuses(onlyPinned: true) { (response) in
guard case let .success(newPinnedStatuses, _) = response else { fatalError() }
self.mastodonController.persistentContainer.addAll(statuses: newPinnedStatuses) {
let oldPinnedStatuses = self.pinnedStatuses
var pinnedStatuses = [(id: String, state: StatusState)]()
for status in newPinnedStatuses {
let state: StatusState
if let (_, oldState) = oldPinnedStatuses.first(where: { $0.id == status.id }) {
state = oldState
} else {
state = .unknown
}
pinnedStatuses.append((status.id, state))
}
DispatchQueue.main.async {
self.pinnedStatuses = pinnedStatuses
UIView.performWithoutAnimation {
self.tableView.reloadSections(IndexSet(integer: 1), with: .none)
}
}
}
}
}
@objc func composePressed(_ sender: Any) {
sendMessageMentioning()
}
}
extension ProfileTableViewController: StatusTableViewCellDelegate {
var apiController: MastodonController { mastodonController }
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
// causes the table view to recalculate the cell heights
tableView.beginUpdates()
tableView.endUpdates()
}
}
extension ProfileTableViewController: ProfileHeaderTableViewCellDelegate {
func showMoreOptions(cell: ProfileHeaderTableViewCell) {
let account = mastodonController.persistentContainer.account(for: accountID)!
func showActivityController(activities: [UIActivity]) {
let activityController = UIActivityViewController(activityItems: [account.url, AccountActivityItemSource(account)], applicationActivities: activities)
activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(viewController: self, url: account.url)
activityController.popoverPresentationController?.sourceView = cell.moreButtonVisualEffectView
self.present(activityController, animated: true)
}
if account.id == mastodonController.account.id {
showActivityController(activities: [OpenInSafariActivity()])
} else {
let request = Client.getRelationships(accounts: [account.id])
mastodonController.run(request) { (response) in
var customActivities: [UIActivity] = [OpenInSafariActivity()]
if case let .success(results, _) = response, let relationship = results.first {
let toggleFollowActivity = relationship.following ? UnfollowAccountActivity() : FollowAccountActivity()
customActivities.insert(toggleFollowActivity, at: 0)
}
DispatchQueue.main.async {
showActivityController(activities: customActivities)
}
}
}
}
}
extension ProfileTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths where indexPath.section > 1 {
let statusID = timelineSegments[indexPath.section - 2][indexPath.row].id
guard let status = mastodonController.persistentContainer.status(for: statusID) else { continue }
_ = ImageCache.avatars.get(status.account.avatar, completion: nil)
for attachment in status.attachments {
_ = ImageCache.attachments.get(attachment.url, completion: nil)
}
}
}
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths where indexPath.section > 1 {
let statusID = timelineSegments[indexPath.section - 2][indexPath.row].id
guard let status = mastodonController.persistentContainer.status(for: statusID) else { continue }
ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
for attachment in status.attachments {
ImageCache.attachments.cancelWithoutCallback(attachment.url)
}
}
}
}

View File

@ -0,0 +1,242 @@
//
// ProfileViewController.swift
// Tusker
//
// Created by Shadowfacts on 7/3/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import Combine
class ProfileViewController: UIPageViewController {
weak var mastodonController: MastodonController!
// todo: does this still need to be settable?
var accountID: String! {
didSet {
updateAccountUI()
pageControllers.forEach { $0.accountID = accountID }
}
}
private var accountUpdater: Cancellable?
private(set) var currentIndex: Int!
let pageControllers: [ProfileStatusesViewController]
var currentViewController: ProfileStatusesViewController {
pageControllers[currentIndex]
}
private var headerView: ProfileHeaderView!
init(accountID: String?, mastodonController: MastodonController) {
self.accountID = accountID
self.mastodonController = mastodonController
self.pageControllers = [
ProfileStatusesViewController(accountID: accountID, kind: .statuses, mastodonController: mastodonController),
ProfileStatusesViewController(accountID: accountID, kind: .withReplies, mastodonController: mastodonController),
ProfileStatusesViewController(accountID: accountID, kind: .onlyMedia, mastodonController: mastodonController)
]
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
mastodonController.persistentContainer.account(for: accountID)?.decrementReferenceCount()
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning))
if #available(iOS 14.0, *) {
composeButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: [
UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in
self.composeDirectMentioning()
})
])
}
navigationItem.rightBarButtonItem = composeButton
headerView = ProfileHeaderView.create()
headerView.delegate = self
selectPage(at: 0, animated: false)
currentViewController.tableView.tableHeaderView = headerView
NSLayoutConstraint.activate([
headerView.widthAnchor.constraint(equalTo: view.widthAnchor),
])
accountUpdater = mastodonController.persistentContainer.accountSubject
.filter { [weak self] in $0 == self?.accountID }
.receive(on: DispatchQueue.main)
.sink { [weak self] (_) in self?.updateAccountUI() }
if mastodonController.persistentContainer.account(for: accountID) != nil {
headerView.updateUI(for: accountID)
updateAccountUI()
} else {
let req = Client.getAccount(id: accountID)
mastodonController.run(req) { [weak self] (response) in
guard let self = self else { return }
guard case let .success(account, _) = response else { fatalError() }
self.mastodonController.persistentContainer.addOrUpdate(account: account, incrementReferenceCount: true) { (account) in
DispatchQueue.main.async {
self.updateAccountUI()
self.headerView.updateUI(for: self.accountID)
self.pageControllers.forEach {
$0.updateUI(account: account)
}
}
}
}
}
}
private func updateAccountUI() {
guard let account = mastodonController.persistentContainer.account(for: accountID) else { return }
navigationItem.title = account.displayNameWithoutCustomEmoji
}
private func selectPage(at index: Int, animated: Bool, completion: ((Bool) -> Void)? = nil) {
let direction: UIPageViewController.NavigationDirection = currentIndex == nil || index - currentIndex > 0 ? .forward : .reverse
currentIndex = index
guard let old = viewControllers?.first as? ProfileStatusesViewController else {
// if old doesn't exist, we're selecting the initial view controller, so moving the header around isn't necessary
// since it will be added in viewDidLoad
setViewControllers([pageControllers[index]], direction: direction, animated: animated, completion: completion)
return
}
let new = pageControllers[index]
let headerHeight = self.headerView.bounds.height
// Store old's content offset so it can be transferred to new
let prevOldContentOffset = old.tableView.contentOffset
// Remove the header, inset the table content by the same amount, and adjust the offset so the cells don't move
old.tableView.tableHeaderView = nil
old.tableView.contentInset = UIEdgeInsets(top: headerHeight, left: 0, bottom: 0, right: 0)
old.tableView.contentOffset.y -= headerHeight
// Add the header to ourself temporarily, and constrain it to the same position it was in
self.view.addSubview(self.headerView)
let tempTopConstraint = self.headerView.topAnchor.constraint(equalTo: self.view.topAnchor, constant: -(prevOldContentOffset.y + old.tableView.safeAreaInsets.top))
NSLayoutConstraint.activate([
self.headerView.widthAnchor.constraint(equalTo: self.view.widthAnchor),
tempTopConstraint
])
// Setup the inset in new, in case it hasn't been already
new.tableView.contentInset = UIEdgeInsets(top: headerHeight, left: 0, bottom: 0, right: 0)
// Match the scroll positions
new.tableView.contentOffset = old.tableView.contentOffset
// Actually switch pages
setViewControllers([pageControllers[index]], direction: direction, animated: animated) { (finished) in
// Defer everything one run-loop iteration, otherwise altering the tableView's contentInset/Offset causes it to jump around during the animation
DispatchQueue.main.async {
// Move the header to the new table view
new.tableView.tableHeaderView = self.headerView
// Remove the inset, and set the offset back to old's original one, prior to removing the header
new.tableView.contentInset = .zero
new.tableView.contentOffset = prevOldContentOffset
// Deactivate the top constraint, otherwise it sticks around
tempTopConstraint.isActive = false
// Re-add the width constraint since it was removed by re-parenting the view
// Why was the width constraint removed, but the top one not? Good question, I have no idea.
NSLayoutConstraint.activate([
self.headerView.widthAnchor.constraint(equalTo: self.view.widthAnchor)
])
// Layout and update the table view, otherwise the content jumps around when first scrolling it,
// if old was not scrolled all the way to the top
new.tableView.layoutIfNeeded()
UIView.performWithoutAnimation {
new.tableView.performBatchUpdates(nil, completion: nil)
}
completion?(finished)
}
}
}
// MARK: Interaction
@objc private func composeMentioning() {
if let account = mastodonController.persistentContainer.account(for: accountID) {
compose(mentioningAcct: account.acct)
}
}
private func composeDirectMentioning() {
if let account = mastodonController.persistentContainer.account(for: accountID) {
let draft = mastodonController.createDraft(mentioningAcct: account.acct)
draft.visibility = .direct
compose(editing: draft)
}
}
}
extension ProfileViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController }
}
extension ProfileViewController: ProfileHeaderViewDelegate {
func profileHeader(_ view: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int) {
// disable user interaction on segmented control while switching pages to prevent
// race condition from trying to switch to multiple pages simultaneously
view.pagesSegmentedControl.isUserInteractionEnabled = false
selectPage(at: newIndex, animated: true) { (finished) in
view.pagesSegmentedControl.isUserInteractionEnabled = true
}
}
func profileHeader(_ view: ProfileHeaderView, showMoreOptionsFor accountID: String, sourceView: UIView) {
let account = mastodonController.persistentContainer.account(for: accountID)!
func showActivityController(activities: [UIActivity]) {
let activityController = UIActivityViewController(activityItems: [account.url, AccountActivityItemSource(account)], applicationActivities: activities)
activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(navigator: self, url: account.url)
activityController.popoverPresentationController?.sourceView = sourceView
self.present(activityController, animated: true)
}
if account.id == mastodonController.account.id {
showActivityController(activities: [OpenInSafariActivity()])
} else {
let request = Client.getRelationships(accounts: [account.id])
mastodonController.run(request) { (response) in
var customActivities: [UIActivity] = [OpenInSafariActivity()]
if case let .success(results, _) = response, let relationship = results.first {
let toggleFollowActivity = relationship.following ? UnfollowAccountActivity() : FollowAccountActivity()
customActivities.insert(toggleFollowActivity, at: 0)
}
DispatchQueue.main.async {
showActivityController(activities: customActivities)
}
}
}
}
}
extension ProfileViewController: TabBarScrollableViewController {
func tabBarScrollToTop() {
pageControllers[currentIndex].tabBarScrollToTop()
}
}

View File

@ -28,7 +28,7 @@ extension SearchResultsViewControllerDelegate {
class SearchResultsViewController: EnhancedTableViewController {
let mastodonController: MastodonController!
weak var mastodonController: MastodonController!
weak var exploreNavigationController: UINavigationController?
weak var delegate: SearchResultsViewControllerDelegate?
@ -109,6 +109,15 @@ class SearchResultsViewController: EnhancedTableViewController {
return super.targetViewController(forAction: action, sender: sender)
}
func loadResults(from source: SearchResultsViewController) {
currentQuery = source.currentQuery
if let sourceDataSource = source.dataSource {
dataSource.apply(sourceDataSource.snapshot())
}
// todo: check if the search needs to be performed before searching
// performSearch(query: currentQuery)
}
func performSearch(query: String?) {
guard let query = query, !query.isEmpty else {
self.dataSource.apply(NSDiffableDataSourceSnapshot<Section, Item>())
@ -134,15 +143,19 @@ class SearchResultsViewController: EnhancedTableViewController {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
self.mastodonController.persistentContainer.performBatchUpdates({ (context, addAccounts, addStatuses) in
if oldSnapshot.indexOfSection(.accounts) != nil {
oldSnapshot.itemIdentifiers(inSection: .accounts).forEach { (item) in
guard case let .account(id) = item else { return }
self.mastodonController.persistentContainer.account(for: id, in: context)?.decrementReferenceCount()
}
}
if oldSnapshot.indexOfSection(.statuses) != nil {
oldSnapshot.itemIdentifiers(inSection: .statuses).forEach { (item) in
guard case let .status(id, _) = item else { return }
self.mastodonController.persistentContainer.status(for: id, in: context)?.decrementReferenceCount()
}
}
if self.onlySections.contains(.accounts) && !results.accounts.isEmpty {
snapshot.appendSections([.accounts])
@ -208,6 +221,20 @@ extension SearchResultsViewController {
case account(String)
case hashtag(Hashtag)
case status(String, StatusState)
func hash(into hasher: inout Hasher) {
switch self {
case let .account(id):
hasher.combine("account")
hasher.combine(id)
case let .hashtag(hashtag):
hasher.combine("hashtag")
hasher.combine(hashtag.url)
case let .status(id, _):
hasher.combine("status")
hasher.combine(id)
}
}
}
class DataSource: UITableViewDiffableDataSource<Section, Item> {

View File

@ -0,0 +1,59 @@
//
// SearchViewController.swift
// Tusker
//
// Created by Shadowfacts on 6/24/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
class SearchViewController: UIViewController {
weak var mastodonController: MastodonController!
var resultsController: SearchResultsViewController!
var searchController: UISearchController!
var searchControllerStatusOnAppearance: Bool? = nil
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil)
title = NSLocalizedString("Search", comment: "search tab title")
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
resultsController = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController
searchController = UISearchController(searchResultsController: resultsController)
searchController.searchResultsUpdater = resultsController
searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.delegate = resultsController
definesPresentationContext = true
navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// this is a workaround for the issue that setting isActive on a search controller that is not visible
// does not cause it to automatically become active once it becomes visible
// see FB7814561
if let active = searchControllerStatusOnAppearance {
searchController.isActive = active
searchControllerStatusOnAppearance = nil
}
}
}

View File

@ -31,6 +31,8 @@ class InstanceTimelineViewController: TimelineTableViewController {
}
}
var browsingEnabled = true
init(for url: URL, parentMastodonController: MastodonController) {
self.parentMastodonController = parentMastodonController
@ -66,36 +68,15 @@ class InstanceTimelineViewController: TimelineTableViewController {
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = super.tableView(tableView, cellForRowAt: indexPath) as! TimelineStatusTableViewCell
cell.delegate = nil
cell.overrideMastodonController = mastodonController
cell.delegate = browsingEnabled ? self : nil
return cell
}
// MARK: - Table view delegate
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
// no-op, we don't currently support viewing whole conversations from other instances
}
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
// don't show other screens or actions for other instances
return nil
}
override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
// don't show swipe actions for other instances
return nil
}
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
// only show more actions for other instances
let more = UIContextualAction(style: .normal, title: "More") { (action, view, completion) in
completion(true)
self.showMoreOptions(forStatus: self.timelineSegments[indexPath.section][indexPath.row].id, sourceView: tableView.cellForRow(at: indexPath))
}
more.image = UIImage(systemName: "ellipsis.circle.fill")
more.backgroundColor = .lightGray
return UISwipeActionsConfiguration(actions: [more])
guard browsingEnabled else { return }
super.tableView(tableView, didSelectRowAt: indexPath)
}
// MARK: - Interaction

View File

@ -9,7 +9,7 @@
import UIKit
import Pachyderm
class TimelineTableViewController: EnhancedTableViewController {
class TimelineTableViewController: EnhancedTableViewController, StatusTableViewCellDelegate {
var timeline: Timeline!
weak var mastodonController: MastodonController!
@ -179,11 +179,12 @@ class TimelineTableViewController: EnhancedTableViewController {
mastodonController.run(request) { response in
guard case let .success(newStatuses, pagination) = response else { fatalError() }
self.older = pagination?.older
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
DispatchQueue.main.async {
let newRows = self.timelineSegments.last!.count..<(self.timelineSegments.last!.count + newStatuses.count)
let newIndexPaths = newRows.map { IndexPath(row: $0, section: self.timelineSegments.count - 1) }
self.timelineSegments[self.timelineSegments.count - 1].append(contentsOf: newStatuses.map { ($0.id, .unknown) })
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
DispatchQueue.main.async {
UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .none)
}
@ -205,7 +206,7 @@ class TimelineTableViewController: EnhancedTableViewController {
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
}
// MARK: Interaction
// MARK: - Interaction
@objc func refreshStatuses(_ sender: Any) {
guard let newer = newer else { return }
@ -213,15 +214,16 @@ class TimelineTableViewController: EnhancedTableViewController {
let request = Client.getStatuses(timeline: timeline, range: newer)
mastodonController.run(request) { response in
guard case let .success(newStatuses, pagination) = response else { fatalError() }
self.newer = pagination?.newer
self.timelineSegments[0].insert(contentsOf: newStatuses.map { ($0.id, .unknown) }, at: 0)
// If there is no new newer pagination, don't reset it, so that the user can continue refreshing for more recent statuses
// Otherwise, when no new statuses were loaded, it would get reset and the the user would be unable to refresh
if let newer = pagination?.newer {
self.newer = newer
}
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
DispatchQueue.main.async {
self.timelineSegments[0].insert(contentsOf: newStatuses.map { ($0.id, .unknown) }, at: 0)
let newIndexPaths = (0..<newStatuses.count).map {
IndexPath(row: $0, section: 0)
}
@ -242,16 +244,18 @@ class TimelineTableViewController: EnhancedTableViewController {
compose()
}
}
// MARK: - TuskerNavigationDelegate
extension TimelineTableViewController: StatusTableViewCellDelegate {
var apiController: MastodonController { mastodonController }
// MARK: - StatusTableViewCellDelegate
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
// causes the table view to recalculate the cell heights
tableView.beginUpdates()
tableView.endUpdates()
}
}
extension TimelineTableViewController: UITableViewDataSourcePrefetching {
@ -259,7 +263,7 @@ extension TimelineTableViewController: UITableViewDataSourcePrefetching {
for indexPath in indexPaths {
guard let status = mastodonController.persistentContainer.status(for: statusID(for: indexPath)) else { continue }
_ = ImageCache.avatars.get(status.account.avatar, completion: nil)
for attachment in status.attachments {
for attachment in status.attachments where attachment.kind == .image {
_ = ImageCache.attachments.get(attachment.url, completion: nil)
}
}
@ -272,7 +276,7 @@ extension TimelineTableViewController: UITableViewDataSourcePrefetching {
guard indexPath.section < timelineSegments.count, indexPath.row < timelineSegments[indexPath.section].count,
let status = mastodonController.persistentContainer.status(for: statusID(for: indexPath)) else { continue }
ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
for attachment in status.attachments {
for attachment in status.attachments where attachment.kind == .image {
ImageCache.attachments.cancelWithoutCallback(attachment.url)
}
}

View File

@ -95,3 +95,13 @@ extension EnhancedTableViewController {
}
}
extension EnhancedTableViewController: TabBarScrollableViewController {
func tabBarScrollToTop() {
if scrollViewShouldScrollToTop(tableView) {
let topOffset = CGPoint(x: 0, y: -tableView.adjustedContentInset.top)
tableView.setContentOffset(topOffset, animated: true)
scrollViewDidScrollToTop(tableView)
}
}
}

View File

@ -10,9 +10,9 @@ import UIKit
import SafariServices
import Pachyderm
protocol MenuPreviewProvider {
protocol MenuPreviewProvider: class {
typealias PreviewProviders = (content: UIContextMenuContentPreviewProvider, actions: () -> [UIAction])
typealias PreviewProviders = (content: UIContextMenuContentPreviewProvider, actions: () -> [UIMenuElement])
var navigationDelegate: TuskerNavigationDelegate? { get }
@ -28,57 +28,188 @@ extension MenuPreviewProvider {
private var mastodonController: MastodonController? { navigationDelegate?.apiController }
func actionsForProfile(accountID: String, sourceView: UIView?) -> [UIAction] {
// Default no-op implementation
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
return nil
}
func actionsForProfile(accountID: String, sourceView: UIView?) -> [UIMenuElement] {
guard let mastodonController = mastodonController,
let account = mastodonController.persistentContainer.account(for: accountID) else { return [] }
guard mastodonController.loggedIn else {
return [
createAction(identifier: "sendmessage", title: "Send Message", systemImageName: "envelope", handler: { (_) in
self.navigationDelegate?.compose(mentioning: account.acct)
}),
createAction(identifier: "openinsafari", title: "Open in Safari", systemImageName: "safari", handler: { (_) in
self.navigationDelegate?.selected(url: account.url)
}),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { (_) in
openInSafariAction(url: account.url),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forAccount: accountID, sourceView: sourceView)
})
]
}
var actionsSection: [UIMenuElement] = [
createAction(identifier: "sendmessage", title: "Send Message", systemImageName: "envelope", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.compose(mentioningAcct: account.acct)
}),
]
// todo: handle pre-iOS 14
#if SDK_IOS_14
if accountID != mastodonController.account.id,
#available(iOS 14.0, *) {
actionsSection.append(UIDeferredMenuElement({ (elementHandler) in
guard let mastodonController = self.mastodonController else {
elementHandler([])
return
}
let request = Client.getRelationships(accounts: [account.id])
mastodonController.run(request) { [weak self] (response) in
if let self = self,
case let .success(results, _) = response,
let relationship = results.first {
let following = relationship.following
DispatchQueue.main.async {
elementHandler([
self.createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.minus", handler: { (_) in
let request = (following ? Account.unfollow : Account.follow)(accountID)
mastodonController.run(request) { (_) in
}
})
])
}
}
}
}))
}
#endif
let shareSection = [
openInSafariAction(url: account.url),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forAccount: accountID, sourceView: sourceView)
})
]
return [
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection),
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: actionsSection),
]
}
func actionsForURL(_ url: URL, sourceView: UIView?) -> [UIAction] {
return [
createAction(identifier: "openinsafari", title: "Open in Safari", systemImageName: "safari", handler: { (_) in
self.navigationDelegate?.selected(url: url)
}),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { (_) in
openInSafariAction(url: url),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forURL: url, sourceView: sourceView)
})
]
}
func actionsForHashtag(_ hashtag: Hashtag, sourceView: UIView?) -> [UIAction] {
return actionsForURL(hashtag.url, sourceView: sourceView)
func actionsForHashtag(_ hashtag: Hashtag, sourceView: UIView?) -> [UIMenuElement] {
let account = mastodonController!.accountInfo!
let saved = SavedDataManager.shared.isSaved(hashtag: hashtag, for: account)
let actionsSection = [
createAction(identifier: "save", title: saved ? "Unsave Hashtag" : "Save Hashtag", systemImageName: "number", handler: { (_) in
if saved {
SavedDataManager.shared.remove(hashtag: hashtag, for: account)
} else {
SavedDataManager.shared.add(hashtag: hashtag, for: account)
}
})
]
let shareSection = actionsForURL(hashtag.url, sourceView: sourceView)
return [
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection),
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: actionsSection),
]
}
func actionsForStatus(statusID: String, sourceView: UIView?) -> [UIAction] {
func actionsForStatus(statusID: String, sourceView: UIView?) -> [UIMenuElement] {
guard let mastodonController = mastodonController,
let status = mastodonController.persistentContainer.status(for: statusID) else { return [] }
guard mastodonController.loggedIn else {
return [
createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { (_) in
self.navigationDelegate?.reply(to: statusID)
}),
createAction(identifier: "openinsafari", title: "Open in Safari", systemImageName: "safari", handler: { (_) in
self.navigationDelegate?.selected(url: status.url!)
}),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { (_) in
openInSafariAction(url: status.url!),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forStatus: statusID, sourceView: sourceView)
})
]
}
let bookmarked = status.bookmarked ?? false
let muted = status.muted
var actionsSection = [
createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.compose(inReplyToID: statusID)
}),
createAction(identifier: "bookmark", title: bookmarked ? "Unbookmark" : "Bookmark", systemImageName: bookmarked ? "bookmark.fill" : "bookmark", handler: { [weak self] (_) in
guard let self = self else { return }
let request = (bookmarked ? Status.unbookmark : Status.bookmark)(statusID)
self.mastodonController?.run(request) { (response) in
if case let .success(status, _) = response {
self.mastodonController?.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
}
}
}),
createAction(identifier: "mute", title: muted ? "Unmute" : "Mute", systemImageName: muted ? "speaker" : "speaker.slash", handler: { [weak self] (_) in
guard let self = self else { return }
let request = (muted ? Status.unmuteConversation : Status.muteConversation)(statusID)
self.mastodonController?.run(request) { (response) in
if case let .success(status, _) = response {
self.mastodonController?.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
}
}
})
]
if mastodonController.account != nil && mastodonController.account.id == status.account.id {
let pinned = status.pinned ?? false
actionsSection.append(createAction(identifier: "", title: pinned ? "Unpin" : "Pin", systemImageName: pinned ? "pin.slash" : "pin", handler: { [weak self] (_) in
guard let self = self else { return }
let request = (pinned ? Status.unpin : Status.pin)(statusID)
self.mastodonController?.run(request, completion: { [weak self] (response) in
guard let self = self else { return }
if case let .success(status, _) = response {
self.mastodonController?.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false)
}
})
}))
}
let shareSection = [
openInSafariAction(url: status.url!),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forStatus: statusID, sourceView: sourceView)
}),
]
return [
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection),
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: actionsSection),
]
}
private func createAction(identifier: String, title: String, systemImageName: String, handler: @escaping UIActionHandler) -> UIAction {
return UIAction(title: title, image: UIImage(systemName: systemImageName), identifier: UIAction.Identifier(identifier), discoverabilityTitle: nil, attributes: [], state: .off, handler: handler)
}
private func openInSafariAction(url: URL) -> UIAction {
return createAction(identifier: "openinsafari", title: "Open in Safari", systemImageName: "safari", handler: { (_) in
self.navigationDelegate?.selected(url: url, allowUniversalLinks: false)
})
}
}
extension LargeImageViewController: CustomPreviewPresenting {

View File

@ -63,3 +63,11 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
}
}
extension SegmentedPageViewController: TabBarScrollableViewController {
func tabBarScrollToTop() {
if let scrollableVC = pageControllers[currentIndex] as? TabBarScrollableViewController {
scrollableVC.tabBarScrollToTop()
}
}
}

View File

@ -0,0 +1,13 @@
//
// TabBarScrollableViewController.swift
// Tusker
//
// Created by Shadowfacts on 7/3/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
protocol TabBarScrollableViewController: UIViewController {
func tabBarScrollToTop()
}

View File

@ -7,6 +7,7 @@
//
import UIKit
import Intents
import Pachyderm
class UserActivityManager {
@ -50,7 +51,8 @@ class UserActivityManager {
static func handleNewPost(activity: NSUserActivity) {
// TODO: check not currently showing compose screen
let mentioning = activity.userInfo?["mentioning"] as? String
let composeVC = ComposeViewController(mentioningAcct: mentioning, mastodonController: mastodonController)
let draft = mastodonController.createDraft(mentioningAcct: mentioning)
let composeVC = ComposeHostingController(draft: draft, mastodonController: mastodonController)
present(UINavigationController(rootViewController: composeVC))
}

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.space.vaccor.Tusker</string>
</array>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.personal-information.photos-library</key>
<true/>
</dict>
</plist>

View File

@ -10,52 +10,11 @@ import UIKit
import SafariServices
import Pachyderm
protocol TuskerNavigationDelegate: class {
protocol TuskerNavigationDelegate: UIViewController {
var apiController: MastodonController { get }
func show(_ vc: UIViewController)
func selected(account accountID: String)
func selected(mention: Mention)
func selected(tag: Hashtag)
func selected(url: URL)
func selected(status statusID: String)
func selected(status statusID: String, state: StatusState)
func compose()
func compose(mentioning: String?)
func reply(to statusID: String)
func reply(to statusID: String, mentioningAcct: String?)
func loadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView) -> LoadingLargeImageViewController
func showLoadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView)
func gallery(attachments: [Attachment], sourceViews: [UIImageView?], startIndex: Int) -> GalleryViewController
func showGallery(attachments: [Attachment], sourceViews: [UIImageView?], startIndex: Int)
func showMoreOptions(forStatus statusID: String, sourceView: UIView?)
func showMoreOptions(forAccount accountID: String, sourceView: UIView?)
func showMoreOptions(forURL url: URL, sourceView: UIView?)
func showFollowedByList(accountIDs: [String])
func statusActionAccountList(action: StatusActionAccountListTableViewController.ActionType, statusID: String, statusState state: StatusState, accountIDs: [String]?) -> StatusActionAccountListTableViewController
}
extension TuskerNavigationDelegate where Self: UIViewController {
extension TuskerNavigationDelegate {
func show(_ vc: UIViewController) {
if vc is LargeImageViewController || vc is GalleryViewController || vc is SFSafariViewController {
@ -67,23 +26,23 @@ extension TuskerNavigationDelegate where Self: UIViewController {
func selected(account accountID: String) {
// don't open if the account is the same as the current one
if let profileController = self as? ProfileTableViewController,
if let profileController = self as? ProfileViewController,
profileController.accountID == accountID {
return
}
show(ProfileTableViewController(accountID: accountID, mastodonController: apiController), sender: self)
show(ProfileViewController(accountID: accountID, mastodonController: apiController), sender: self)
}
func selected(mention: Mention) {
show(ProfileTableViewController(accountID: mention.id, mastodonController: apiController), sender: self)
show(ProfileViewController(accountID: mention.id, mastodonController: apiController), sender: self)
}
func selected(tag: Hashtag) {
show(HashtagTimelineViewController(for: tag, mastodonController: apiController), sender: self)
}
func selected(url: URL) {
func selected(url: URL, allowUniversalLinks: Bool = true) {
func openSafari() {
if Preferences.shared.useInAppSafari {
let config = SFSafariViewController.Configuration()
@ -94,7 +53,7 @@ extension TuskerNavigationDelegate where Self: UIViewController {
}
}
if (Preferences.shared.openLinksInApps) {
if allowUniversalLinks && Preferences.shared.openLinksInApps {
UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { (success) in
if (!success) {
openSafari()
@ -120,27 +79,17 @@ extension TuskerNavigationDelegate where Self: UIViewController {
show(ConversationTableViewController(for: statusID, state: state, mastodonController: apiController), sender: self)
}
// protocols can't have parameter defaults, so this stub is necessary to fulfill the protocol req
func compose() {
compose(mentioning: nil)
}
func compose(editing draft: Draft) {
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
func compose(mentioning: String?) {
let compose = ComposeViewController(mentioningAcct: mentioning, mastodonController: apiController)
let vc = UINavigationController(rootViewController: compose)
vc.presentationController?.delegate = compose
present(vc, animated: true)
}
func reply(to statusID: String) {
reply(to: statusID, mentioningAcct: nil)
}
func reply(to statusID: String, mentioningAcct: String?) {
let compose = ComposeViewController(inReplyTo: statusID, mentioningAcct: mentioningAcct, mastodonController: apiController)
let vc = UINavigationController(rootViewController: compose)
vc.presentationController?.delegate = compose
present(vc, animated: true)
func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil) {
let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct)
compose(editing: draft)
}
func loadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView) -> LoadingLargeImageViewController {
@ -169,7 +118,7 @@ extension TuskerNavigationDelegate where Self: UIViewController {
OpenInSafariActivity()
]
let activityController = UIActivityViewController(activityItems: [url], applicationActivities: customActivites)
activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(viewController: self, url: url)
activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(navigator: self, url: url)
return activityController
}
@ -177,6 +126,10 @@ extension TuskerNavigationDelegate where Self: UIViewController {
guard let status = apiController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID)") }
guard let url = status.url else { fatalError("Missing url for status \(statusID)") }
// on iOS 14+, all these custom actions are in the context menu and don't need to be in the share sheet
if #available(iOS 14.0, *) {
return UIActivityViewController(activityItems: [url, StatusActivityItemSource(status)], applicationActivities: nil)
} else {
var customActivites: [UIActivity] = [
OpenInSafariActivity(),
(status.bookmarked ?? false) ? UnbookmarkStatusActivity() : BookmarkStatusActivity(),
@ -189,21 +142,26 @@ extension TuskerNavigationDelegate where Self: UIViewController {
}
let activityController = UIActivityViewController(activityItems: [url, StatusActivityItemSource(status)], applicationActivities: customActivites)
activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(viewController: self, url: url)
activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(navigator: self, url: url)
return activityController
}
}
private func moreOptions(forAccount accountID: String) -> UIActivityViewController {
guard let account = apiController.persistentContainer.account(for: accountID) else { fatalError("Missing cached account \(accountID)") }
if #available(iOS 14.0, *) {
return UIActivityViewController(activityItems: [account.url, AccountActivityItemSource(account)], applicationActivities: nil)
} else {
let customActivities: [UIActivity] = [
OpenInSafariActivity(),
]
let activityController = UIActivityViewController(activityItems: [account.url, AccountActivityItemSource(account)], applicationActivities: customActivities)
activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(viewController: self, url: account.url)
activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(navigator: self, url: account.url)
return activityController
}
}
func showMoreOptions(forStatus statusID: String, sourceView: UIView?) {
let vc = moreOptions(forStatus: statusID)

148
Tusker/Vendor/BlurHashDecode.swift vendored Normal file
View File

@ -0,0 +1,148 @@
/// BlurHash reference decoder implementation.
/// From https://github.com/woltapp/blurhash/blob/b23214ddcab803fe1ec9a3e6b20558caf33a23a5/Swift/BlurHashDecode.swift
import UIKit
extension UIImage {
public convenience init?(blurHash: String, size: CGSize, punch: Float = 1) {
guard blurHash.count >= 6 else { return nil }
let sizeFlag = String(blurHash[0]).decode83()
let numY = (sizeFlag / 9) + 1
let numX = (sizeFlag % 9) + 1
let quantisedMaximumValue = String(blurHash[1]).decode83()
let maximumValue = Float(quantisedMaximumValue + 1) / 166
guard blurHash.count == 4 + 2 * numX * numY else { return nil }
let colours: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in
if i == 0 {
let value = String(blurHash[2 ..< 6]).decode83()
return decodeDC(value)
} else {
let value = String(blurHash[4 + i * 2 ..< 4 + i * 2 + 2]).decode83()
return decodeAC(value, maximumValue: maximumValue * punch)
}
}
let width = Int(size.width)
let height = Int(size.height)
let bytesPerRow = width * 3
guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil }
CFDataSetLength(data, bytesPerRow * height)
guard let pixels = CFDataGetMutableBytePtr(data) else { return nil }
for y in 0 ..< height {
for x in 0 ..< width {
var r: Float = 0
var g: Float = 0
var b: Float = 0
for j in 0 ..< numY {
for i in 0 ..< numX {
let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height))
let colour = colours[i + j * numX]
r += colour.0 * basis
g += colour.1 * basis
b += colour.2 * basis
}
}
let intR = UInt8(linearTosRGB(r))
let intG = UInt8(linearTosRGB(g))
let intB = UInt8(linearTosRGB(b))
pixels[3 * x + 0 + y * bytesPerRow] = intR
pixels[3 * x + 1 + y * bytesPerRow] = intG
pixels[3 * x + 2 + y * bytesPerRow] = intB
}
}
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
guard let provider = CGDataProvider(data: data) else { return nil }
guard let cgImage = CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 24, bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: bitmapInfo, provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent) else { return nil }
self.init(cgImage: cgImage)
}
}
private func decodeDC(_ value: Int) -> (Float, Float, Float) {
let intR = value >> 16
let intG = (value >> 8) & 255
let intB = value & 255
return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB))
}
private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) {
let quantR = value / (19 * 19)
let quantG = (value / 19) % 19
let quantB = value % 19
let rgb = (
signPow((Float(quantR) - 9) / 9, 2) * maximumValue,
signPow((Float(quantG) - 9) / 9, 2) * maximumValue,
signPow((Float(quantB) - 9) / 9, 2) * maximumValue
)
return rgb
}
private func signPow(_ value: Float, _ exp: Float) -> Float {
return copysign(pow(abs(value), exp), value)
}
private func linearTosRGB(_ value: Float) -> Int {
let v = max(0, min(1, value))
if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) }
else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) }
}
private func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float {
let v = Float(Int64(value)) / 255
if v <= 0.04045 { return v / 12.92 }
else { return pow((v + 0.055) / 1.055, 2.4) }
}
private let encodeCharacters: [String] = {
return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) }
}()
private let decodeCharacters: [String: Int] = {
var dict: [String: Int] = [:]
for (index, character) in encodeCharacters.enumerated() {
dict[character] = index
}
return dict
}()
private extension String {
func decode83() -> Int {
var value: Int = 0
for character in self {
if let digit = decodeCharacters[String(character)] {
value = value * 83 + digit
}
}
return value
}
}
private extension String {
subscript (offset: Int) -> Character {
return self[index(startIndex, offsetBy: offset)]
}
subscript (bounds: CountableClosedRange<Int>) -> Substring {
let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound)
return self[start...end]
}
subscript (bounds: CountableRange<Int>) -> Substring {
let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound)
return self[start..<end]
}
}

Some files were not shown because too many files have changed in this diff Show More