Compare commits

..

38 Commits

Author SHA1 Message Date
Shadowfacts 1c871a12a1
Bump build number and update changelog 2020-10-21 18:08:20 -04:00
Shadowfacts 8a528936b8
Fix crash when tapping My Profile tab too quickly after app launch 2020-10-19 18:41:38 -04:00
Shadowfacts 744329dca2
Upload photos taken from UIImagePickerController as JPEGs instead of PNGs 2020-10-19 18:33:10 -04:00
Shadowfacts 45ac40b125
Fix broken Compose layout when replying to long statuses 2020-10-18 16:31:41 -04:00
Shadowfacts 2426989161
Fix unsatisfiable constraints in timeline status action buttons
UIStackView internal constraints all have a required priority, so adding
the image constrain in TimelineStatusTableViewCell.awakeFromNib caused
an unsatisfiable constraint. Fixed by replicating the UISV constraints
manually, with the constrain on the leading edge of the first button
being made a placeholder.
2020-10-18 14:42:17 -04:00
Shadowfacts 1439c8b162
Fix unsatisfiable constraints on attachment container view
The stack view hiding constraint sets the height to 0 with a priority of
999.999, so the priority 1000 aspect ratio constraint was causing an
error and making the container view still have a height. Setting the
priority to 999 resolves the issue.
2020-10-18 13:50:52 -04:00
Shadowfacts 5125cc3397
Show custom emojis in display names in follow/favorite/reblog
notifications
2020-10-18 12:22:12 -04:00
Shadowfacts 9b949af390
Add complete emoji list to Compose emoji autocomplete 2020-10-18 11:17:58 -04:00
Shadowfacts 3ff9fdabdb
Use MultiThreadedDictionary for ImageCache request groups
Prevents a crash due a race condition if multiple requets complete
simultaneously and attempt to modify the dictionary
2020-10-18 11:03:56 -04:00
Shadowfacts a805da9faa
Enable picture-in-picture playback for video attachments 2020-10-17 12:56:13 -04:00
Shadowfacts e0acb0f04a
Don't search for unnecessary data 2020-10-16 19:14:29 -04:00
Shadowfacts 5414f2329c
Fix race condition causing My Profile tab bar image to not be set 2020-10-14 19:34:30 -04:00
Shadowfacts 08045dd1e9
Prioritize followed/following accounts in mention suggestions 2020-10-14 19:28:32 -04:00
Shadowfacts 288f855e2f
Support positing large image VC controls in iPhone 12/Pro/Mini ears 2020-10-13 21:12:21 -04:00
Shadowfacts 7883b04618
Fix autocomplete bar cutting off scroll view contents 2020-10-12 22:12:35 -04:00
Shadowfacts 0687c040a0
Prevent inserting extra whitespace when autocompleting 2020-10-12 22:03:50 -04:00
Shadowfacts 58c6d508ec
Prevent caret from changing position on auto complete 2020-10-12 19:39:50 -04:00
Shadowfacts ae272582ac
Autocomplete custom emojis in CW field 2020-10-12 19:17:57 -04:00
Shadowfacts 1a4517c43a
Cache account relationships in CoreData 2020-10-12 18:20:57 -04:00
Shadowfacts 2cfc0cf28a
Add Compose screen mention, hashtag, emoji completion
Closes #10
2020-10-11 22:14:45 -04:00
Shadowfacts cf63384dce
Why use many DispatchQueue.main.async when few do trick? 2020-09-25 11:31:53 -04:00
Shadowfacts 733d50b642
Strip U+FFFC from status bodies when posting
Fixes #112
2020-09-23 17:20:45 -04:00
Shadowfacts 0e60e74a8a
Fix being able to rotate into landscape on iOS 14 2020-09-21 18:45:52 -04:00
Shadowfacts fd0054addf
Correctly round corners of My Profile tab icon 2020-09-21 18:42:06 -04:00
Shadowfacts 576e4aa90d
Add conversation screen title 2020-09-21 18:24:02 -04:00
Shadowfacts ea3de4cdda
Fix wrong icon in context menu action 2020-09-21 18:18:55 -04:00
Shadowfacts 83c7609df5
Fix crash when using app icon shortcuts 2020-09-21 18:11:28 -04:00
Shadowfacts 809584cc54
Fix crash when opening Compose screen before account/instance is loaded
Prevents when opening the Compose screen with poor network connectivity
2020-09-21 18:04:08 -04:00
Shadowfacts 9b85090884
Add debug environment variable to disable image caching 2020-09-21 18:03:51 -04:00
Shadowfacts 6965a4c374
Remove no longer necessary iOS 13.4 availability checks 2020-09-20 11:34:46 -04:00
Shadowfacts b6c0c02028
Remove no longer necessary conditional compilation directives 2020-09-20 11:34:09 -04:00
Shadowfacts 42f9d19ee9
Fix memory leak in attachment container view 2020-09-20 11:28:18 -04:00
Shadowfacts b80a61cc95
WeakArray improvements 2020-09-20 11:27:14 -04:00
Shadowfacts 0d972d987c
Remove old Compose screen cold 2020-09-20 10:46:51 -04:00
Shadowfacts 3e33c8e6f9
Minimize file system requests during image cache lookup 2020-09-17 21:53:51 -04:00
Shadowfacts 3822d536c8
Reduce redundant status database lookups when updating cell UI 2020-09-17 21:53:47 -04:00
Shadowfacts 5906c374ba
Fix not being able to tap text view placeholders on Compose screen
Fixes #111
2020-09-17 18:40:02 -04:00
Shadowfacts ee90b20f7f
Add swipe to remove accounts in Preferences 2020-09-16 22:21:12 -04:00
76 changed files with 2249 additions and 580 deletions

View File

@ -1,5 +1,33 @@
# Changelog # Changelog
## 2020.1 (11)
This release is primarily focused on bug fixes with the one key feature of autocomplete suggestions when typing in the Compose screen. It also fixes an issue on the various new sizes of iPhone 12, so if you're getting a new device, make sure to update.
Features/Improvements:
- Add autocompletion on Compose screen
- Autocomplete provides suggestions for @-mentions, hashtags, and emojis as you're typing in the post body
- Provides suggestions for emojis as you're typing in the CW field
- Type a colon and expand the emoji suggestions to view all custom emoji on your instance
- Hashtag suggestions prioritize trending and saved hashtags, in addition to searching all hashtags on your instance
- Account suggestions prioritize accounts that you follow or that follow you, as well as searching all accounts known to your instance
- Show custom emojis in users' display names in follow, favorite, and reblog notifications
- Enable picture-in-picture playback of video attachments
- iOS 14: Automatically enter picture-in-picture when closing the app while a video is playing
- Correctly positiong gallery controls on iPhone 12-family devices
- Round corners of the avatar on the My Profile tab icon
- Remove extraneous U+FFFC characters inserted by dictation when posting
- Add swipe to remove accounts in Preferences
Bugfixes:
- Fix not being able to tap placeholders in Compose
- Fix broken layout on Compose screen when replying to very long posts
- Fix crash when opening Compose or My Profile too quickly after launch
- Upload photos taken with the in-app camera as JPEGs instead of PNGs
- Fixes an issue where Mastodon would incorrectly believe the file size to be too large
- Fix crash when using home screen shortcuts
- Disable rotating into landscape on iPhone on iOS 14
- Fix assorted other crashes and memory leaks
## 2020.1 (10) ## 2020.1 (10)
This build is a hotfix for a couple pressing issues. The changelog for the previous build is included below. This build is a hotfix for a couple pressing issues. The changelog for the previous build is included below.

View File

@ -52,10 +52,11 @@ public class Client {
self.session = session self.session = session
} }
public func run<Result>(_ request: Request<Result>, completion: @escaping Callback<Result>) { @discardableResult
public func run<Result>(_ request: Request<Result>, completion: @escaping Callback<Result>) -> URLSessionTask? {
guard let request = createURLRequest(request: request) else { guard let request = createURLRequest(request: request) else {
completion(.failure(Error.invalidRequest)) completion(.failure(Error.invalidRequest))
return return nil
} }
let task = session.dataTask(with: request) { data, response, error in let task = session.dataTask(with: request) { data, response, error in
@ -83,6 +84,7 @@ public class Client {
completion(.success(result, pagination)) completion(.success(result, pagination))
} }
task.resume() task.resume()
return task
} }
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? { func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
@ -276,12 +278,12 @@ public class Client {
} }
// MARK: - Search // MARK: - Search
public static func search(query: String, resolve: Bool? = nil, limit: Int? = nil) -> Request<SearchResults> { public static func search(query: String, types: [SearchResultType]? = nil, resolve: Bool? = nil, limit: Int? = nil) -> Request<SearchResults> {
return Request<SearchResults>(method: .get, path: "/api/v2/search", queryParameters: [ return Request<SearchResults>(method: .get, path: "/api/v2/search", queryParameters: [
"q" => query, "q" => query,
"resolve" => resolve, "resolve" => resolve,
"limit" => limit "limit" => limit,
]) ] + "types" => types?.map { $0.rawValue })
} }
// MARK: - Statuses // MARK: - Statuses
@ -314,13 +316,24 @@ public class Client {
} }
// MARK: Bookmarks // MARK: - Bookmarks
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> { public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks") var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
request.range = range request.range = range
return request return request
} }
// MARK: - Trends
public static func getTrends(limit: Int? = nil) -> Request<[Hashtag]> {
let parameters: [Parameter]
if let limit = limit {
parameters = ["limit" => limit]
} else {
parameters = []
}
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends", queryParameters: parameters)
}
} }
extension Client { extension Client {

View File

@ -18,6 +18,7 @@ public class Relationship: Decodable {
public let followRequested: Bool public let followRequested: Bool
public let domainBlocking: Bool public let domainBlocking: Bool
public let showingReblogs: Bool public let showingReblogs: Bool
public let endorsed: Bool?
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id case id
@ -29,5 +30,6 @@ public class Relationship: Decodable {
case followRequested = "requested" case followRequested = "requested"
case domainBlocking = "domain_blocking" case domainBlocking = "domain_blocking"
case showingReblogs = "showing_reblogs" case showingReblogs = "showing_reblogs"
case endorsed
} }
} }

View File

@ -0,0 +1,15 @@
//
// SearchResultType.swift
// Pachyderm
//
// Created by Shadowfacts on 10/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
public enum SearchResultType: String {
case accounts
case hashtags
case statuses
}

View File

@ -140,7 +140,7 @@
D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC19123C271D9000D0238 /* MastodonActivity.swift */; }; D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC19123C271D9000D0238 /* MastodonActivity.swift */; };
D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AAC2128D88B005A6F37 /* LocalData.swift */; }; D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AAC2128D88B005A6F37 /* LocalData.swift */; };
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; }; D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
D64D8CA92463B494006B0BAA /* CachedDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* CachedDictionary.swift */; }; D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; };
D64F80E2215875CC00BEF393 /* XCBActionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64F80E1215875CC00BEF393 /* XCBActionType.swift */; }; D64F80E2215875CC00BEF393 /* XCBActionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64F80E1215875CC00BEF393 /* XCBActionType.swift */; };
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */; }; D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */; };
D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */; }; D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */; };
@ -160,6 +160,7 @@
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F42135BCD50057A976 /* ConversationTableViewController.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 */; }; D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */; };
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; }; D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; };
D670F8B62537DC890046588A /* EmojiPickerWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D670F8B52537DC890046588A /* EmojiPickerWrapper.swift */; };
D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7B2157E01900721E32 /* XCBManager.swift */; }; D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7B2157E01900721E32 /* XCBManager.swift */; };
D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */; }; D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */; };
D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A812157E8FA00721E32 /* XCBSession.swift */; }; D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A812157E8FA00721E32 /* XCBSession.swift */; };
@ -173,8 +174,6 @@
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67B506C250B291200FAECFB /* BlurHashDecode.swift */; }; D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67B506C250B291200FAECFB /* BlurHashDecode.swift */; };
D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AC21E265FC00C3118B /* LargeAccountDetailView.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 */; }; D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */; };
D67C57B221E28FAD00C3118B /* ComposeStatusReplyView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D67C57B121E28FAD00C3118B /* ComposeStatusReplyView.xib */; };
D67C57B421E2910700C3118B /* ComposeStatusReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57B321E2910700C3118B /* ComposeStatusReplyView.swift */; };
D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */; }; D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */; };
D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68015412401A74600D6103B /* MediaPrefsView.swift */; }; D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68015412401A74600D6103B /* MediaPrefsView.swift */; };
D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681A299249AD62D0085E54E /* LargeImageContentView.swift */; }; D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681A299249AD62D0085E54E /* LargeImageContentView.swift */; };
@ -186,6 +185,8 @@
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */; }; D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */; };
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; }; D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; };
D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525C24A3E8F00054355A /* SearchViewController.swift */; }; D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525C24A3E8F00054355A /* SearchViewController.swift */; };
D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */; };
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */; };
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.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 */; }; D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D690797224A4EF9700023A34 /* UIBezierPath+Helpers.swift */; };
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */; }; D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */; };
@ -240,6 +241,9 @@
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; }; D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; };
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */; }; D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */; };
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; }; D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; };
D6C143DA253510F4007DC240 /* ComposeContentWarningTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeContentWarningTextField.swift */; };
D6C143E025354E34007DC240 /* EmojiPickerCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143DF25354E34007DC240 /* EmojiPickerCollectionViewController.swift */; };
D6C143FD25354FD0007DC240 /* EmojiCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143FB25354FD0007DC240 /* EmojiCollectionViewCell.swift */; };
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; }; D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; };
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; }; D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; };
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; }; D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; };
@ -262,9 +266,16 @@
D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; }; D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; };
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; }; D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; };
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */; }; D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */; };
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */; };
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */; }; D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */; };
D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */; }; D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */; };
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E0DC8D216EDF1E00369478 /* Previewing.swift */; }; D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E0DC8D216EDF1E00369478 /* Previewing.swift */; };
D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */; };
D6E426812532814100C02E1C /* MaybeLazyStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426802532814100C02E1C /* MaybeLazyStack.swift */; };
D6E4269D2532A3E100C02E1C /* FuzzyMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */; };
D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */; };
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */; };
D6E426B9253382B300C02E1C /* SearchResultType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426B8253382B300C02E1C /* SearchResultType.swift */; };
D6E6F26321603F8B006A8599 /* CharacterCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26221603F8B006A8599 /* CharacterCounter.swift */; }; D6E6F26321603F8B006A8599 /* CharacterCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26221603F8B006A8599 /* CharacterCounter.swift */; };
D6E6F26521604242006A8599 /* CharacterCounterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26421604242006A8599 /* CharacterCounterTests.swift */; }; D6E6F26521604242006A8599 /* CharacterCounterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26421604242006A8599 /* CharacterCounterTests.swift */; };
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; }; D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; };
@ -465,7 +476,7 @@
D64BC19123C271D9000D0238 /* MastodonActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonActivity.swift; sourceTree = "<group>"; }; D64BC19123C271D9000D0238 /* MastodonActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonActivity.swift; sourceTree = "<group>"; };
D64D0AAC2128D88B005A6F37 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = "<group>"; }; D64D0AAC2128D88B005A6F37 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = "<group>"; };
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; }; D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
D64D8CA82463B494006B0BAA /* CachedDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedDictionary.swift; sourceTree = "<group>"; }; D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = "<group>"; };
D64F80E1215875CC00BEF393 /* XCBActionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBActionType.swift; sourceTree = "<group>"; }; D64F80E1215875CC00BEF393 /* XCBActionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBActionType.swift; sourceTree = "<group>"; };
D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentView.swift; sourceTree = "<group>"; }; D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentView.swift; sourceTree = "<group>"; };
D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentViewController.swift; sourceTree = "<group>"; }; D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentViewController.swift; sourceTree = "<group>"; };
@ -489,6 +500,7 @@
D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTableViewController.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>"; }; D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Equatable.swift"; sourceTree = "<group>"; };
D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; }; D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; };
D670F8B52537DC890046588A /* EmojiPickerWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerWrapper.swift; sourceTree = "<group>"; };
D6757A7B2157E01900721E32 /* XCBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBManager.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; D6757A812157E8FA00721E32 /* XCBSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBSession.swift; sourceTree = "<group>"; };
@ -502,8 +514,6 @@
D67B506C250B291200FAECFB /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.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>"; }; 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>"; }; 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>"; };
D67C57B321E2910700C3118B /* ComposeStatusReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusReplyView.swift; sourceTree = "<group>"; };
D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposingPrefsView.swift; sourceTree = "<group>"; }; D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposingPrefsView.swift; sourceTree = "<group>"; };
D68015412401A74600D6103B /* MediaPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPrefsView.swift; sourceTree = "<group>"; }; D68015412401A74600D6103B /* MediaPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPrefsView.swift; sourceTree = "<group>"; };
D681A299249AD62D0085E54E /* LargeImageContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageContentView.swift; sourceTree = "<group>"; }; D681A299249AD62D0085E54E /* LargeImageContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageContentView.swift; sourceTree = "<group>"; };
@ -515,6 +525,8 @@
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedProgressView.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>"; }; 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>"; }; D68E525C24A3E8F00054355A /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = "<group>"; };
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiSourceEmojiLabel.swift; sourceTree = "<group>"; };
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseEmojiLabel.swift; sourceTree = "<group>"; };
D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.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>"; }; 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>"; }; D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedNavigationViewController.swift; sourceTree = "<group>"; };
@ -564,6 +576,9 @@
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.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>"; }; D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = "<group>"; };
D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; }; D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; };
D6C143D9253510F4007DC240 /* ComposeContentWarningTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeContentWarningTextField.swift; sourceTree = "<group>"; };
D6C143DF25354E34007DC240 /* EmojiPickerCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerCollectionViewController.swift; sourceTree = "<group>"; };
D6C143FB25354FD0007DC240 /* EmojiCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCollectionViewCell.swift; sourceTree = "<group>"; };
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.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>"; }; D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; };
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; }; D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; };
@ -592,9 +607,16 @@
D6D4DDF1212518A200E1C4BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; D6D4DDF1212518A200E1C4BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; }; D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; };
D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Notification.swift"; sourceTree = "<group>"; }; D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Notification.swift"; sourceTree = "<group>"; };
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelationshipMO.swift; sourceTree = "<group>"; };
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadScrollGestureRecognizer.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; D6E0DC8D216EDF1E00369478 /* Previewing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Previewing.swift; sourceTree = "<group>"; };
D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAutocompleteView.swift; sourceTree = "<group>"; };
D6E426802532814100C02E1C /* MaybeLazyStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaybeLazyStack.swift; sourceTree = "<group>"; };
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuzzyMatcher.swift; sourceTree = "<group>"; };
D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuzzyMatcherTests.swift; sourceTree = "<group>"; };
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiImageView.swift; sourceTree = "<group>"; };
D6E426B8253382B300C02E1C /* SearchResultType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultType.swift; sourceTree = "<group>"; };
D6E4885C24A2890C0011C13E /* Tusker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Tusker.entitlements; 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>"; }; 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>"; }; D6E6F26421604242006A8599 /* CharacterCounterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCounterTests.swift; sourceTree = "<group>"; };
@ -757,6 +779,7 @@
D61099F82145698900432DC2 /* Relationship.swift */, D61099F82145698900432DC2 /* Relationship.swift */,
D61099FA214569F600432DC2 /* Report.swift */, D61099FA214569F600432DC2 /* Report.swift */,
D61099FC21456A1D00432DC2 /* SearchResults.swift */, D61099FC21456A1D00432DC2 /* SearchResults.swift */,
D6E426B8253382B300C02E1C /* SearchResultType.swift */,
D61099FE21456A4C00432DC2 /* Status.swift */, D61099FE21456A4C00432DC2 /* Status.swift */,
D6285B4E21EA695800FE4B39 /* StatusContentType.swift */, D6285B4E21EA695800FE4B39 /* StatusContentType.swift */,
D6109A10214607D500432DC2 /* Timeline.swift */, D6109A10214607D500432DC2 /* Timeline.swift */,
@ -884,6 +907,7 @@
D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */, D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */,
D60E2F232442372B005F8713 /* StatusMO.swift */, D60E2F232442372B005F8713 /* StatusMO.swift */,
D60E2F252442372B005F8713 /* AccountMO.swift */, D60E2F252442372B005F8713 /* AccountMO.swift */,
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */,
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */, D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
); );
path = CoreData; path = CoreData;
@ -991,6 +1015,11 @@
D622757724EE133700B82A16 /* ComposeAssetPicker.swift */, D622757724EE133700B82A16 /* ComposeAssetPicker.swift */,
D62275A524F1C81800B82A16 /* ComposeReplyView.swift */, D62275A524F1C81800B82A16 /* ComposeReplyView.swift */,
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */, D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */,
D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */,
D6C143D9253510F4007DC240 /* ComposeContentWarningTextField.swift */,
D6C143DF25354E34007DC240 /* EmojiPickerCollectionViewController.swift */,
D6C143FB25354FD0007DC240 /* EmojiCollectionViewCell.swift */,
D670F8B52537DC890046588A /* EmojiPickerWrapper.swift */,
); );
path = Compose; path = Compose;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1145,15 +1174,6 @@
path = "Account Detail"; path = "Account Detail";
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D67C57B021E28F9400C3118B /* Compose Status Reply */ = {
isa = PBXGroup;
children = (
D67C57B121E28FAD00C3118B /* ComposeStatusReplyView.xib */,
D67C57B321E2910700C3118B /* ComposeStatusReplyView.swift */,
);
path = "Compose Status Reply";
sourceTree = "<group>";
};
D6A3BC7223218C6E00FD64D5 /* Utilities */ = { D6A3BC7223218C6E00FD64D5 /* Utilities */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -1262,7 +1282,9 @@
D620483323D3801D008A63EF /* LinkTextView.swift */, D620483323D3801D008A63EF /* LinkTextView.swift */,
D620483523D38075008A63EF /* ContentTextView.swift */, D620483523D38075008A63EF /* ContentTextView.swift */,
D620483723D38190008A63EF /* StatusContentTextView.swift */, D620483723D38190008A63EF /* StatusContentTextView.swift */,
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */,
D6969E9F240C8384002843CE /* EmojiLabel.swift */, D6969E9F240C8384002843CE /* EmojiLabel.swift */,
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */, D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */,
04ED00B021481ED800567C53 /* SteppedProgressView.swift */, 04ED00B021481ED800567C53 /* SteppedProgressView.swift */,
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */, D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
@ -1272,8 +1294,9 @@
D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */, D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */,
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */, D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */,
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */, D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
D6E426802532814100C02E1C /* MaybeLazyStack.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
D67C57A721E2649B00C3118B /* Account Detail */, D67C57A721E2649B00C3118B /* Account Detail */,
D67C57B021E28F9400C3118B /* Compose Status Reply */,
D626494023C122C800612E6E /* Asset Picker */, D626494023C122C800612E6E /* Asset Picker */,
D61959D0241E842400A37B8E /* Draft Cell */, D61959D0241E842400A37B8E /* Draft Cell */,
D641C78A213DD926004B4513 /* Status */, D641C78A213DD926004B4513 /* Status */,
@ -1352,9 +1375,10 @@
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */, D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */, D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */,
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */, D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */,
D64D8CA82463B494006B0BAA /* CachedDictionary.swift */, D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */, D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */, D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
D67B506B250B28FF00FAECFB /* Vendor */, D67B506B250B28FF00FAECFB /* Vendor */,
D6F1F84E2193B9BE00F5FE67 /* Caching */, D6F1F84E2193B9BE00F5FE67 /* Caching */,
D6757A7A2157E00100721E32 /* XCallbackURL */, D6757A7A2157E00100721E32 /* XCallbackURL */,
@ -1380,6 +1404,7 @@
children = ( children = (
D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */, D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */,
D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */, D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */,
D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */,
D6D4DDE6212518A200E1C4BB /* Info.plist */, D6D4DDE6212518A200E1C4BB /* Info.plist */,
); );
path = TuskerTests; path = TuskerTests;
@ -1645,7 +1670,6 @@
D626493D23C1000300612E6E /* AlbumTableViewCell.xib in Resources */, D626493D23C1000300612E6E /* AlbumTableViewCell.xib in Resources */,
D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */, D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */,
D64BC19023C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib in Resources */, D64BC19023C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib in Resources */,
D67C57B221E28FAD00C3118B /* ComposeStatusReplyView.xib in Resources */,
D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */, D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */,
D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */, D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */,
D6F2E966249E8BFD005846BB /* CrashReporterViewController.xib in Resources */, D6F2E966249E8BFD005846BB /* CrashReporterViewController.xib in Resources */,
@ -1711,6 +1735,7 @@
D61099CB2144B20500432DC2 /* Request.swift in Sources */, D61099CB2144B20500432DC2 /* Request.swift in Sources */,
D6109A05214572BF00432DC2 /* Scope.swift in Sources */, D6109A05214572BF00432DC2 /* Scope.swift in Sources */,
D6109A11214607D500432DC2 /* Timeline.swift in Sources */, D6109A11214607D500432DC2 /* Timeline.swift in Sources */,
D6E426B9253382B300C02E1C /* SearchResultType.swift in Sources */,
D60E2F3324425374005F8713 /* AccountProtocol.swift in Sources */, D60E2F3324425374005F8713 /* AccountProtocol.swift in Sources */,
D61099E7214561FF00432DC2 /* Attachment.swift in Sources */, D61099E7214561FF00432DC2 /* Attachment.swift in Sources */,
D60E2F3124424F1A005F8713 /* StatusProtocol.swift in Sources */, D60E2F3124424F1A005F8713 /* StatusProtocol.swift in Sources */,
@ -1805,11 +1830,13 @@
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */, D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */, D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */, D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */,
D6C143FD25354FD0007DC240 /* EmojiCollectionViewCell.swift in Sources */,
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */, D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */, D60E2F272442372B005F8713 /* StatusMO.swift in Sources */,
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */, D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */,
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */, D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */,
D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */, D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */,
D6E426812532814100C02E1C /* MaybeLazyStack.swift in Sources */,
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */, D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */,
D6A3BC852321F6C100FD64D5 /* AccountListTableViewController.swift in Sources */, D6A3BC852321F6C100FD64D5 /* AccountListTableViewController.swift in Sources */,
D64BC18823C1640A000D0238 /* PinStatusActivity.swift in Sources */, D64BC18823C1640A000D0238 /* PinStatusActivity.swift in Sources */,
@ -1818,7 +1845,7 @@
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */, D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */,
D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */, D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */,
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */, D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
D67C57B421E2910700C3118B /* ComposeStatusReplyView.swift in Sources */, D6C143E025354E34007DC240 /* EmojiPickerCollectionViewController.swift in Sources */,
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */, D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */, D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */, D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,
@ -1833,6 +1860,7 @@
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */, D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */,
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */, D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */, D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */,
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */,
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */, D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */,
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */, D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */,
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */, D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */,
@ -1844,6 +1872,7 @@
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */, D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */,
D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */, D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */,
D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */, D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */,
D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */,
D63F9C6E241D2D85004C03CF /* CompositionAttachment.swift in Sources */, D63F9C6E241D2D85004C03CF /* CompositionAttachment.swift in Sources */,
D6AEBB4523216AF800E5038B /* FollowAccountActivity.swift in Sources */, D6AEBB4523216AF800E5038B /* FollowAccountActivity.swift in Sources */,
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */, D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */,
@ -1852,6 +1881,7 @@
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */, D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */,
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */, D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */, D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */,
D6C143DA253510F4007DC240 /* ComposeContentWarningTextField.swift in Sources */,
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */, 0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */,
D627943923A553B600D38C68 /* UnbookmarkStatusActivity.swift in Sources */, D627943923A553B600D38C68 /* UnbookmarkStatusActivity.swift in Sources */,
D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */, D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */,
@ -1865,6 +1895,7 @@
D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */, D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */,
D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */, D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */,
D6434EB3215B1856001A919A /* XCBRequest.swift in Sources */, D6434EB3215B1856001A919A /* XCBRequest.swift in Sources */,
D6E4269D2532A3E100C02E1C /* FuzzyMatcher.swift in Sources */,
D681E4D3246E2AFF0053414F /* MuteConversationActivity.swift in Sources */, D681E4D3246E2AFF0053414F /* MuteConversationActivity.swift in Sources */,
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */, D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */,
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */, D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */,
@ -1893,6 +1924,7 @@
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */, D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */,
D64F80E2215875CC00BEF393 /* XCBActionType.swift in Sources */, D64F80E2215875CC00BEF393 /* XCBActionType.swift in Sources */,
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */, 04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */,
D670F8B62537DC890046588A /* EmojiPickerWrapper.swift in Sources */,
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */, D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */,
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */, D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */, D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */,
@ -1915,8 +1947,9 @@
D641C773213CAA25004B4513 /* NotificationsTableViewController.swift in Sources */, D641C773213CAA25004B4513 /* NotificationsTableViewController.swift in Sources */,
D6412B0524B0227D00F5412E /* ProfileViewController.swift in Sources */, D6412B0524B0227D00F5412E /* ProfileViewController.swift in Sources */,
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */, D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */,
D64D8CA92463B494006B0BAA /* CachedDictionary.swift in Sources */, D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */, D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */,
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
D6F1F84D2193B56E00F5FE67 /* Cache.swift in Sources */, D6F1F84D2193B56E00F5FE67 /* Cache.swift in Sources */,
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */, D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,
0427037C22B316B9000D31B6 /* SilentActionPrefs.swift in Sources */, 0427037C22B316B9000D31B6 /* SilentActionPrefs.swift in Sources */,
@ -1926,11 +1959,13 @@
D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */, D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */,
D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */, D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */,
D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */, D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */,
D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */,
D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */, D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */,
D677284E24ECC01D00C732D3 /* Draft.swift in Sources */, D677284E24ECC01D00C732D3 /* Draft.swift in Sources */,
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */, D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */, D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,
D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */, D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */,
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */, D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */, D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */, D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */,
@ -1944,6 +1979,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */, D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */,
D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */,
D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */, D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -2229,7 +2265,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.4; IPHONEOS_DEPLOYMENT_TARGET = 13.4;
@ -2258,7 +2294,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.4; IPHONEOS_DEPLOYMENT_TARGET = 13.4;

View File

@ -89,6 +89,13 @@
isEnabled = "YES"> isEnabled = "YES">
</CommandLineArgument> </CommandLineArgument>
</CommandLineArguments> </CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "DISABLE_IMAGE_CACHE"
value = "1"
isEnabled = "NO">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"

View File

@ -29,10 +29,13 @@ class FollowAccountActivity: AccountActivity {
let request = Account.follow(account.id) let request = Account.follow(account.id)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
if case .failure(_) = response { switch response {
case .failure(_):
// todo: display error message // todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error) UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError() fatalError()
case let .success(relationship, _):
self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
} }
} }
} }

View File

@ -29,10 +29,13 @@ class UnfollowAccountActivity: AccountActivity {
let request = Account.unfollow(account.id) let request = Account.unfollow(account.id)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
if case .failure(_) = response { switch response {
case .failure(_):
// todo: display error message // todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error) UINotificationFeedbackGenerator().notificationOccurred(.error)
fatalError() fatalError()
case let .success(relationship, _):
self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
} }
} }
} }

View File

@ -1,35 +0,0 @@
//
// CachedDictionary.swift
// Tusker
//
// Created by Shadowfacts on 5/6/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
class CachedDictionary<Value> {
private let name: String
private var dict = [String: Value]()
private let queue: DispatchQueue
init(name: String) {
self.name = name
self.queue = DispatchQueue(label: "CachedDictionary (\(name)) Coordinator", attributes: .concurrent)
}
subscript(key: String) -> Value? {
get {
var result: Value? = nil
queue.sync {
result = dict[key]
}
return result
}
set(value) {
queue.async(flags: .barrier) {
self.dict[key] = value
}
}
}
}

View File

@ -15,6 +15,7 @@ enum Cache<T> {
case disk(DiskStorage<T>) case disk(DiskStorage<T>)
case hybrid(HybridStorage<T>) case hybrid(HybridStorage<T>)
@available(*, deprecated, message: "disk-based caches synchronously interact with the file system. Avoid using if possible.")
func existsObject(forKey key: String) throws -> Bool { func existsObject(forKey key: String) throws -> Bool {
switch self { switch self {
case let .memory(memory): case let .memory(memory):

View File

@ -15,10 +15,18 @@ class ImageCache {
static let headers = ImageCache(name: "Headers", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60)) static let headers = ImageCache(name: "Headers", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60))
static let attachments = ImageCache(name: "Attachments", memoryExpiry: .seconds(60 * 2)) static let attachments = ImageCache(name: "Attachments", memoryExpiry: .seconds(60 * 2))
static let emojis = ImageCache(name: "Emojis", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60)) static let emojis = ImageCache(name: "Emojis", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60))
#if DEBUG
private static let disableCaching = ProcessInfo.processInfo.environment.keys.contains("DISABLE_IMAGE_CACHE")
#else
private static let disableCaching = false
#endif
private let cache: Cache<Data> private let cache: Cache<Data>
private var groups = [URL: RequestGroup]() private var groups = MultiThreadDictionary<URL, RequestGroup>(name: "ImageCache request groups")
private var backgroundQueue = DispatchQueue(label: "ImageCache completion queue", qos: .default)
init(name: String, memoryExpiry expiry: Expiry) { init(name: String, memoryExpiry expiry: Expiry) {
let storage = MemoryStorage<Data>(config: MemoryConfig(expiry: expiry)) let storage = MemoryStorage<Data>(config: MemoryConfig(expiry: expiry))
@ -38,9 +46,15 @@ class ImageCache {
func get(_ url: URL, completion: ((Data?) -> Void)?) -> Request? { func get(_ url: URL, completion: ((Data?) -> Void)?) -> Request? {
let key = url.absoluteString let key = url.absoluteString
if (try? cache.existsObject(forKey: key)) ?? false, if !ImageCache.disableCaching,
let data = try? cache.object(forKey: key) { // todo: calling object(forKey: key) does disk I/O and this method is often called from the main thread
completion?(data) // in performance sensitive paths. a nice optimization to DiskStorage would be adding an internal cache
// of the state (unknown/exists/does not exist) of whether or not objects exist on disk so that the slow, disk I/O
// path can be avoided most of the time
let data = try? cache.object(forKey: key) {
backgroundQueue.async {
completion?(data)
}
return nil return nil
} else { } else {
if let completion = completion, let group = groups[url] { if let completion = completion, let group = groups[url] {
@ -50,7 +64,7 @@ class ImageCache {
if let data = data { if let data = data {
try? self.cache.setObject(data, forKey: key) try? self.cache.setObject(data, forKey: key)
} }
self.groups.removeValue(forKey: url) self.groups.removeValueWithoutReturning(forKey: url)
} }
groups[url] = group groups[url] = group
let request = group.addCallback(completion) let request = group.addCallback(completion)

View File

@ -9,7 +9,7 @@
import Foundation import Foundation
import Pachyderm import Pachyderm
class MastodonController { class MastodonController: ObservableObject {
static private(set) var all = [LocalData.UserAccountInfo: MastodonController]() static private(set) var all = [LocalData.UserAccountInfo: MastodonController]()
@ -42,8 +42,9 @@ class MastodonController {
let client: Client! let client: Client!
var account: Account! @Published private(set) var account: Account!
var instance: Instance! @Published private(set) var instance: Instance!
private(set) var customEmojis: [Emoji]?
var loggedIn: Bool { var loggedIn: Bool {
accountInfo != nil accountInfo != nil
@ -56,8 +57,9 @@ class MastodonController {
self.transient = transient self.transient = transient
} }
func run<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) { @discardableResult
client.run(request, completion: completion) func run<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) -> URLSessionTask? {
return client.run(request, completion: completion)
} }
func registerApp(completion: @escaping (_ clientID: String, _ clientSecret: String) -> Void) { func registerApp(completion: @escaping (_ clientID: String, _ clientSecret: String) -> Void) {
@ -95,7 +97,9 @@ class MastodonController {
completion?(.failure(error)) completion?(.failure(error))
case let .success(account, _): case let .success(account, _):
self.account = account DispatchQueue.main.async {
self.account = account
}
self.persistentContainer.backgroundContext.perform { self.persistentContainer.backgroundContext.perform {
if let accountMO = self.persistentContainer.account(for: account.id, in: self.persistentContainer.backgroundContext) { if let accountMO = self.persistentContainer.account(for: account.id, in: self.persistentContainer.backgroundContext) {
accountMO.updateFrom(apiAccount: account, container: self.persistentContainer) accountMO.updateFrom(apiAccount: account, container: self.persistentContainer)
@ -118,13 +122,28 @@ class MastodonController {
let request = Client.getInstance() let request = Client.getInstance()
run(request) { (response) in run(request) { (response) in
guard case let .success(instance, _) = response else { fatalError() } guard case let .success(instance, _) = response else { fatalError() }
self.instance = instance DispatchQueue.main.async {
completion?(instance) self.instance = instance
completion?(instance)
}
}
}
}
func getCustomEmojis(completion: @escaping ([Emoji]) -> Void) {
if let emojis = self.customEmojis {
completion(emojis)
} else {
let request = Client.getCustomEmoji()
run(request) { (response) in
if case let .success(emojis, _) = response {
self.customEmojis = emojis
completion(emojis)
} else {
completion([])
}
} }
} }
} }
} }
// ObservableObject so that SwiftUI views can receive it through @EnvironmentObject
extension MastodonController: ObservableObject {}

View File

@ -26,6 +26,7 @@ class MastodonCachePersistentStore: NSPersistentContainer {
let statusSubject = PassthroughSubject<String, Never>() let statusSubject = PassthroughSubject<String, Never>()
let accountSubject = PassthroughSubject<String, Never>() let accountSubject = PassthroughSubject<String, Never>()
let relationshipSubject = PassthroughSubject<String, Never>()
init(for accountInfo: LocalData.UserAccountInfo?, transient: Bool = false) { init(for accountInfo: LocalData.UserAccountInfo?, transient: Bool = false) {
if transient { if transient {
@ -136,6 +137,40 @@ class MastodonCachePersistentStore: NSPersistentContainer {
} }
} }
func relationship(forAccount id: String, in context: NSManagedObjectContext? = nil) -> RelationshipMO? {
let context = context ?? viewContext
let request: NSFetchRequest<RelationshipMO> = RelationshipMO.fetchRequest()
request.predicate = NSPredicate(format: "accountID = %@", id)
request.fetchLimit = 1
if let result = try? context.fetch(request), let relationship = result.first {
return relationship
} else {
return nil
}
}
@discardableResult
private func upsert(relationship: Relationship) -> RelationshipMO {
if let relationshipMO = self.relationship(forAccount: relationship.id, in: self.backgroundContext) {
relationshipMO.updateFrom(apiRelationship: relationship, container: self)
return relationshipMO
} else {
let relationshipMO = RelationshipMO(apiRelationship: relationship, container: self, context: self.backgroundContext)
return relationshipMO
}
}
func addOrUpdate(relationship: Relationship, completion: ((RelationshipMO) -> Void)? = nil) {
backgroundContext.perform {
let relationshipMO = self.upsert(relationship: relationship)
if self.backgroundContext.hasChanges {
try! self.backgroundContext.save()
}
completion?(relationshipMO)
self.relationshipSubject.send(relationship.id)
}
}
func addAll(accounts: [Account], completion: (() -> Void)? = nil) { func addAll(accounts: [Account], completion: (() -> Void)? = nil) {
backgroundContext.perform { backgroundContext.perform {
accounts.forEach { self.upsert(account: $0, incrementReferenceCount: true) } accounts.forEach { self.upsert(account: $0, incrementReferenceCount: true) }

View File

@ -0,0 +1,59 @@
//
// RelationshipMO.swift
// Tusker
//
// Created by Shadowfacts on 10/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
import CoreData
import Pachyderm
@objc(RelationshipMO)
public final class RelationshipMO: NSManagedObject {
@nonobjc public class func fetchRequest() -> NSFetchRequest<RelationshipMO> {
return NSFetchRequest<RelationshipMO>(entityName: "Relationship")
}
@NSManaged public var accountID: String
@NSManaged public var blocking: Bool
@NSManaged public var domainBlocking: Bool
@NSManaged public var endorsed: Bool
@NSManaged public var followedBy: Bool
@NSManaged public var following: Bool
@NSManaged public var muting: Bool
@NSManaged public var mutingNotifications: Bool
@NSManaged public var requested: Bool
@NSManaged public var showingReblogs: Bool
@NSManaged public var account: AccountMO?
}
extension RelationshipMO {
convenience init(apiRelationship relationship: Relationship, container: MastodonCachePersistentStore, context: NSManagedObjectContext) {
self.init(context: context)
self.updateFrom(apiRelationship: relationship, container: container)
}
func updateFrom(apiRelationship relationship: Relationship, container: MastodonCachePersistentStore) {
guard let context = managedObjectContext else {
// we have been deleted, don't bother updating
return
}
self.accountID = relationship.id
self.blocking = relationship.blocking
self.domainBlocking = relationship.domainBlocking
self.endorsed = relationship.endorsed ?? false
self.followedBy = relationship.followedBy
self.following = relationship.following
self.muting = relationship.muting
self.mutingNotifications = relationship.mutingNotifications
self.requested = relationship.followRequested
self.showingReblogs = relationship.showingReblogs
self.account = container.account(for: relationship.id, in: context)
}
}

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="16119" systemVersion="19D76" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17507" systemVersion="19G2021" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="AccountMO" syncable="YES"> <entity name="Account" representedClassName="AccountMO" syncable="YES">
<attribute name="acct" attributeType="String"/> <attribute name="acct" attributeType="String"/>
<attribute name="avatar" attributeType="URI"/> <attribute name="avatar" attributeType="URI"/>
@ -20,12 +20,26 @@
<attribute name="url" attributeType="URI"/> <attribute name="url" attributeType="URI"/>
<attribute name="username" attributeType="String"/> <attribute name="username" attributeType="String"/>
<relationship name="movedTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account"/> <relationship name="movedTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account"/>
<relationship name="relationship" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Relationship" inverseName="account" inverseEntity="Relationship"/>
<uniquenessConstraints> <uniquenessConstraints>
<uniquenessConstraint> <uniquenessConstraint>
<constraint value="id"/> <constraint value="id"/>
</uniquenessConstraint> </uniquenessConstraint>
</uniquenessConstraints> </uniquenessConstraints>
</entity> </entity>
<entity name="Relationship" representedClassName="RelationshipMO" syncable="YES">
<attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="blocking" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="domainBlocking" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="endorsed" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="followedBy" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="following" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="muting" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="mutingNotifications" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="requested" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="showingReblogs" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="relationship" inverseEntity="Account"/>
</entity>
<entity name="Status" representedClassName="StatusMO" syncable="YES"> <entity name="Status" representedClassName="StatusMO" syncable="YES">
<attribute name="applicationName" optional="YES" attributeType="String"/> <attribute name="applicationName" optional="YES" attributeType="String"/>
<attribute name="attachmentsData" attributeType="Binary"/> <attribute name="attachmentsData" attributeType="Binary"/>
@ -59,7 +73,8 @@
</uniquenessConstraints> </uniquenessConstraints>
</entity> </entity>
<elements> <elements>
<element name="Account" positionX="169.21875" positionY="78.9609375" width="128" height="328"/> <element name="Account" positionX="169.21875" positionY="78.9609375" width="128" height="343"/>
<element name="Status" positionX="-63" positionY="-18" width="128" height="418"/> <element name="Status" positionX="-63" positionY="-18" width="128" height="418"/>
<element name="Relationship" positionX="63" positionY="135" width="128" height="208"/>
</elements> </elements>
</model> </model>

62
Tusker/FuzzyMatcher.swift Normal file
View File

@ -0,0 +1,62 @@
//
// FuzzyMatcher.swift
// Tusker
//
// Created by Shadowfacts on 10/10/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
struct FuzzyMatcher {
private init() {}
/// Rudimentary string fuzzy matching algorithm.
///
/// Operates on UTF-8 code points, so attempting to match strings which include characters composed of
/// multiple code points may produce unexpected results.
///
/// Scoring is as follows:
/// +2 points for every char in `pattern` that occurs in `str` sequentially
/// -2 points for every char in `pattern` that does not occur in `str` sequentially
/// -1 point for every char in `str` skipped between matching chars from the `pattern`
static func match(pattern: String, str: String) -> (matched: Bool, score: Int) {
let pattern = pattern.lowercased()
let str = str.lowercased()
var patternIndex = pattern.utf8.startIndex
var lastStrMatchIndex: String.UTF8View.Index?
var strIndex = str.utf8.startIndex
var score = 0
while patternIndex < pattern.utf8.endIndex && strIndex < str.utf8.endIndex {
let patternChar = pattern.utf8[patternIndex]
let strChar = str.utf8[strIndex]
if patternChar == strChar {
let distance = str.utf8.distance(from: lastStrMatchIndex ?? str.utf8.startIndex, to: strIndex)
if distance > 1 {
score -= distance - 1
}
patternIndex = pattern.utf8.index(after: patternIndex)
lastStrMatchIndex = strIndex
strIndex = str.utf8.index(after: strIndex)
score += 2
} else {
strIndex = str.utf8.index(after: strIndex)
if strIndex >= str.utf8.endIndex {
patternIndex = pattern.utf8.index(after: patternIndex)
strIndex = str.utf8.index(after: lastStrMatchIndex ?? str.utf8.startIndex)
score -= 2
}
}
}
return (score > 0, score)
}
}

View File

@ -81,6 +81,10 @@
</array> </array>
</dict> </dict>
</dict> </dict>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key> <key>UIRequiredDeviceCapabilities</key>

View File

@ -51,7 +51,10 @@ enum CompositionAttachmentData {
func getData(completion: @escaping (_ data: Data, _ mimeType: String) -> Void) { func getData(completion: @escaping (_ data: Data, _ mimeType: String) -> Void) {
switch self { switch self {
case let .image(image): case let .image(image):
completion(image.pngData()!, "image/png") // Export as JPEG instead of PNG, otherweise photos straight from the camera are too large
// for Mastodon in its default configuration (max of 10MB).
// The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future.
completion(image.jpegData(compressionQuality: 0.8)!, "image/jpeg")
case let .asset(asset): case let .asset(asset):
if asset.mediaType == .image { if asset.mediaType == .image {
let options = PHImageRequestOptions() let options = PHImageRequestOptions()

View File

@ -29,6 +29,12 @@ class Draft: Codable, ObservableObject {
attachments.count > 0 attachments.count > 0
} }
var textForPosting: String {
// when using dictation, iOS sometimes leaves a U+FFFC OBJECT REPLACEMENT CHARACTER behind in the text,
// which we want to strip out before actually posting the status
text.replacingOccurrences(of: "\u{fffc}", with: "")
}
init(accountID: String) { init(accountID: String) {
self.id = UUID() self.id = UUID()
self.lastModified = Date() self.lastModified = Date()

View File

@ -0,0 +1,50 @@
//
// MultiThreadDictionary.swift
// Tusker
//
// Created by Shadowfacts on 5/6/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
class MultiThreadDictionary<Key: Hashable, Value> {
private let name: String
private var dict = [Key: Value]()
private let queue: DispatchQueue
init(name: String) {
self.name = name
self.queue = DispatchQueue(label: "MultiThreadDictionary (\(name)) Coordinator", attributes: .concurrent)
}
subscript(key: Key) -> Value? {
get {
var result: Value? = nil
queue.sync {
result = dict[key]
}
return result
}
set(value) {
queue.async(flags: .barrier) {
self.dict[key] = value
}
}
}
func removeValueWithoutReturning(forKey key: Key) {
queue.async(flags: .barrier) {
self.dict.removeValue(forKey: key)
}
}
/// If the result of this function is unused, it is preferable to use `removeValueWithoutReturning` as it executes asynchronously and doesn't block the calling thread.
func removeValue(forKey key: Key) -> Value? {
var value: Value? = nil
queue.sync(flags: .barrier) {
value = dict.removeValue(forKey: key)
}
return value
}
}

View File

@ -158,15 +158,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
mastodonController.getOwnInstance() mastodonController.getOwnInstance()
let rootController: UIViewController let rootController: UIViewController
#if SDK_IOS_14
if #available(iOS 14.0, *) { if #available(iOS 14.0, *) {
rootController = MainSplitViewController(mastodonController: mastodonController) rootController = MainSplitViewController(mastodonController: mastodonController)
} else { } else {
rootController = MainTabBarViewController(mastodonController: mastodonController) rootController = MainTabBarViewController(mastodonController: mastodonController)
} }
#else
rootController = MainTabBarViewController(mastodonController: mastodonController)
#endif
window!.rootViewController = rootController window!.rootViewController = rootController
} }

View File

@ -8,9 +8,18 @@
import UIKit import UIKit
import AVKit import AVKit
import Pachyderm
class GalleryPlayerViewController: AVPlayerViewController { class GalleryPlayerViewController: AVPlayerViewController {
var attachment: Attachment!
override func viewDidLoad() {
super.viewDidLoad()
allowsPictureInPicturePlayback = true
}
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)

View File

@ -12,11 +12,13 @@ import AVKit
class GalleryViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, LargeImageAnimatableViewController { class GalleryViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, LargeImageAnimatableViewController {
weak var avPlayerViewControllerDelegate: AVPlayerViewControllerDelegate?
let attachments: [Attachment] let attachments: [Attachment]
let sourceViews: WeakArray<UIImageView> let sourceViews: WeakArray<UIImageView>
let startIndex: Int let startIndex: Int
let pages: [UIViewController] var pages: [UIViewController]!
var currentIndex: Int { var currentIndex: Int {
guard let vc = viewControllers?.first, guard let vc = viewControllers?.first,
@ -72,6 +74,18 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
self.sourceViews = WeakArray(sourceViews) self.sourceViews = WeakArray(sourceViews)
self.startIndex = startIndex self.startIndex = startIndex
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal)
modalPresentationStyle = .fullScreen
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
self.pages = attachments.enumerated().map { (index, attachment) in self.pages = attachments.enumerated().map { (index, attachment) in
switch attachment.kind { switch attachment.kind {
case .image: case .image:
@ -82,6 +96,8 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
case .video, .audio: case .video, .audio:
let vc = GalleryPlayerViewController() let vc = GalleryPlayerViewController()
vc.player = AVPlayer(url: attachment.url) vc.player = AVPlayer(url: attachment.url)
vc.delegate = avPlayerViewControllerDelegate
vc.attachment = attachment
return vc return vc
case .gifv: case .gifv:
// Passing the source view to the LargeImageGifvContentView is a crappy workaround for not // Passing the source view to the LargeImageGifvContentView is a crappy workaround for not
@ -101,19 +117,7 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
} }
} }
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal)
setViewControllers([pages[startIndex]], direction: .forward, animated: false) setViewControllers([pages[startIndex]], direction: .forward, animated: false)
modalPresentationStyle = .fullScreen
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
self.dataSource = self self.dataSource = self
self.delegate = self self.delegate = self

View File

@ -91,7 +91,9 @@ struct ComposeAttachmentsList: View {
} }
private var canAddAttachment: Bool { private var canAddAttachment: Bool {
switch mastodonController.instance.instanceType { switch mastodonController.instance?.instanceType {
case nil:
return false
case .pleroma: case .pleroma:
return true return true
case .mastodon: case .mastodon:

View File

@ -0,0 +1,404 @@
//
// ComposeAutocompleteView.swift
// Tusker
//
// Created by Shadowfacts on 10/10/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
import CoreData
import Pachyderm
struct ComposeAutocompleteView: View {
let autocompleteState: ComposeUIState.AutocompleteState
@Environment(\.colorScheme) var colorScheme: ColorScheme
private var backgroundColor: Color {
Color(white: colorScheme == .light ? 0.98 : 0.15)
}
private var borderColor: Color {
Color(white: colorScheme == .light ? 0.85 : 0.25)
}
var body: some View {
suggestionsView
// animate changes of the scroll view items
.animation(.default)
.background(backgroundColor)
.overlay(borderColor.frame(height: 0.5), alignment: .top)
}
@ViewBuilder
private var suggestionsView: some View {
switch autocompleteState {
case .mention(_):
ComposeAutocompleteMentionsView()
case .emoji(_):
ComposeAutocompleteEmojisView()
case .hashtag(_):
ComposeAutocompleteHashtagsView()
}
}
}
fileprivate extension View {
@ViewBuilder
func iOS13OnlyPadding() -> some View {
// on iOS 13, if the scroll view content's height changes after the view is added to the hierarchy,
// it doesn't appear on screen until interactive keyboard dismissal is started and then cancelled :S
if #available(iOS 14.0, *) {
self
} else {
self.frame(height: 46)
}
}
}
struct ComposeAutocompleteMentionsView: View {
@EnvironmentObject private var mastodonController: MastodonController
@EnvironmentObject private var uiState: ComposeUIState
@ObservedObject private var preferences = Preferences.shared
// can't use AccountProtocol because of associated type requirements
@State private var accounts: [EitherAccount] = []
@State private var searchRequest: URLSessionTask?
var body: some View {
ScrollView(.horizontal) {
// can't use LazyHStack because changing the contents of the ForEach causes the ScrollView to hang
HStack(spacing: 8) {
ForEach(accounts, id: \.id) { (account) in
Button {
uiState.autocompleteHandler?.autocomplete(with: "@\(account.acct)")
} label: {
HStack(spacing: 4) {
ComposeAvatarImageView(url: account.avatar)
.frame(width: 30, height: 30)
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 30)
VStack(alignment: .leading) {
switch account {
case let .pachyderm(underlying):
AccountDisplayNameLabel(account: underlying, fontSize: 14)
.foregroundColor(Color(UIColor.label))
case let .coreData(underlying):
AccountDisplayNameLabel(account: underlying, fontSize: 14)
.foregroundColor(Color(UIColor.label))
}
Text(verbatim: "@\(account.acct)")
.font(.system(size: 12))
.foregroundColor(Color(UIColor.label))
}
}
}
.frame(height: 30)
.padding(.vertical, 8)
.animation(.linear(duration: 0.1))
}
Spacer()
}
.padding(.horizontal, 8)
.iOS13OnlyPadding()
}
.onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged)
.onDisappear {
searchRequest?.cancel()
}
}
private func queryChanged(_ state: ComposeUIState.AutocompleteState?) {
guard case let .mention(query) = state,
!query.isEmpty else {
accounts = []
return
}
let localSearchWorkItem = DispatchWorkItem {
// todo: there's got to be something more efficient than this :/
let wildcardedQuery = query.map { "*\($0)" }.joined() + "*"
let request: NSFetchRequest<AccountMO> = AccountMO.fetchRequest()
request.predicate = NSPredicate(format: "displayName LIKE %@ OR acct LIKE %@", wildcardedQuery, wildcardedQuery)
if let results = try? mastodonController.persistentContainer.viewContext.fetch(request) {
loadAccounts(results.map { .coreData($0) }, query: query)
}
}
// we only want to search locally if the search API call takes more than .25sec or it fails
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: localSearchWorkItem)
if let oldRequest = searchRequest {
oldRequest.cancel()
}
let apiRequest = Client.searchForAccount(query: query)
searchRequest = mastodonController.run(apiRequest) { (response) in
guard case let .success(accounts, _) = response else { return }
localSearchWorkItem.cancel()
// dispatch back to the main thread because loadAccounts uses CoreData
DispatchQueue.main.async {
// if the query has changed, don't bother loading the now-outdated results
if case .mention(query) = uiState.autocompleteState {
self.loadAccounts(accounts.map { .pachyderm($0) }, query: query)
}
}
}
}
private func loadAccounts(_ accounts: [EitherAccount], query: String) {
// when sorting account suggestions, ignore the domain component of the acct unless the user is typing it themself
let ignoreDomain = !query.contains("@")
self.accounts =
accounts.map { (account: EitherAccount) -> (EitherAccount, (matched: Bool, score: Int)) in
let fuzzyStr = ignoreDomain ? String(account.acct.split(separator: "@").first!) : account.acct
let res = (account, FuzzyMatcher.match(pattern: query, str: fuzzyStr))
return res
}
.filter(\.1.matched)
.map { (account, res) -> (EitherAccount, Int) in
// give higher weight to accounts that the user follows or is followed by
var score = res.score
if let relationship = mastodonController.persistentContainer.relationship(forAccount: account.id) {
if relationship.following {
score += 3
}
if relationship.followedBy {
score += 2
}
}
return (account, score)
}
.sorted { $0.1 > $1.1 }
.map(\.0)
}
private enum EitherAccount {
case pachyderm(Account)
case coreData(AccountMO)
var id: String {
switch self {
case let .pachyderm(account):
return account.id
case let .coreData(account):
return account.id
}
}
var acct: String {
switch self {
case let .pachyderm(account):
return account.acct
case let .coreData(account):
return account.acct
}
}
var avatar: URL {
switch self {
case let .pachyderm(account):
return account.avatar
case let .coreData(account):
return account.avatar
}
}
}
}
struct ComposeAutocompleteEmojisView: View {
@EnvironmentObject private var mastodonController: MastodonController
@EnvironmentObject private var uiState: ComposeUIState
@State var expanded = false
@State private var emojis: [Emoji] = []
var body: some View {
// When exapnded, the toggle button should be at the top. When collapsed, it should be centered.
HStack(alignment: expanded ? .top : .center, spacing: 0) {
if case let .emoji(query) = uiState.autocompleteState {
emojiList(query: query)
.animation(.default)
.transition(.move(edge: .bottom))
} else {
// when the autocomplete view is animating out, the autocomplete state is nil
// add a spacer so the expand button remains on the right
Spacer()
}
toggleExpandedButton
.padding(.trailing, 8)
.padding(.top, expanded ? 8 : 0)
}
}
@ViewBuilder
private func emojiList(query: String) -> some View {
if expanded {
EmojiPickerWrapper(searchQuery: query)
.frame(height: 150)
} else {
horizontalScrollView
.onReceive(uiState.$autocompleteState, perform: queryChanged)
}
}
private var horizontalScrollView: some View {
ScrollView(.horizontal) {
HStack(spacing: 8) {
ForEach(emojis, id: \.shortcode) { (emoji) in
Button {
uiState.autocompleteHandler?.autocomplete(with: ":\(emoji.shortcode):")
} label: {
HStack(spacing: 4) {
CustomEmojiImageView(emoji: emoji)
.frame(height: 30)
Text(verbatim: ":\(emoji.shortcode):")
.foregroundColor(Color(UIColor.label))
}
}
.frame(height: 30)
.padding(.vertical, 8)
.animation(.linear(duration: 0.2))
}
Spacer(minLength: 30)
}
.padding(.horizontal, 8)
.frame(height: 46)
}
}
private var toggleExpandedButton: some View {
Button {
expanded.toggle()
} label: {
Image(systemName: expanded ? "chevron.down" : "chevron.up")
.resizable()
.aspectRatio(contentMode: .fit)
}
.frame(width: 20, height: 20)
}
private func queryChanged(_ autocompleteState: ComposeUIState.AutocompleteState?) {
guard case let .emoji(query) = autocompleteState,
!query.isEmpty else {
emojis = []
return
}
mastodonController.getCustomEmojis { (emojis) in
self.emojis =
emojis.map { (emoji) -> (Emoji, (matched: Bool, score: Int)) in
(emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode))
}
.filter(\.1.matched)
.sorted { $0.1.score > $1.1.score }
.map(\.0)
}
}
}
struct ComposeAutocompleteHashtagsView: View {
@EnvironmentObject private var mastodonController: MastodonController
@EnvironmentObject private var uiState: ComposeUIState
@State private var hashtags: [Hashtag] = []
@State private var trendingRequest: URLSessionTask?
@State private var searchRequest: URLSessionTask?
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 8) {
ForEach(hashtags, id: \.name) { (hashtag) in
Button {
uiState.autocompleteHandler?.autocomplete(with: "#\(hashtag.name)")
} label: {
Text(verbatim: "#\(hashtag.name)")
.foregroundColor(Color(UIColor.label))
}
.frame(height: 30)
.padding(.vertical, 8)
.animation(.linear(duration: 0.1))
}
Spacer()
}
.padding(.horizontal, 8)
.iOS13OnlyPadding()
}
.onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged)
.onDisappear {
trendingRequest?.cancel()
}
}
private func queryChanged(_ autocompleteState: ComposeUIState.AutocompleteState?) {
guard case let .hashtag(query) = autocompleteState,
!query.isEmpty else {
hashtags = []
return
}
let onlySavedTagsWorkItem = DispatchWorkItem {
self.updateHashtags(searchResults: [], trendingTags: [], query: query)
}
// we only want to do the local-only search if the trends API call takes more than .25sec or it fails
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: onlySavedTagsWorkItem)
var trendingTags: [Hashtag] = []
var searchedTags: [Hashtag] = []
let group = DispatchGroup()
group.enter()
trendingRequest = mastodonController.run(Client.getTrends()) { (response) in
defer { group.leave() }
guard case let .success(trends, _) = response else { return }
trendingTags = trends
}
group.enter()
searchRequest = mastodonController.run(Client.search(query: "#\(query)", types: [.hashtags])) { (response) in
defer { group.leave() }
guard case let .success(results, _) = response else { return }
searchedTags = results.hashtags
}
group.notify(queue: .main) {
onlySavedTagsWorkItem.cancel()
// if the query has changed, don't bother loading the now-outdated results
if case .hashtag(query) = self.uiState.autocompleteState {
self.updateHashtags(searchResults: searchedTags, trendingTags: trendingTags, query: query)
}
}
}
private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], query: String) {
let savedTags = SavedDataManager.shared.sortedHashtags(for: mastodonController.accountInfo!)
hashtags = (searchResults + savedTags + trendingTags)
.map { (tag) -> (Hashtag, (matched: Bool, score: Int)) in
return (tag, FuzzyMatcher.match(pattern: query, str: tag.name))
}
.filter(\.1.matched)
.sorted { $0.1.score > $1.1.score }
.map(\.0)
}
}
struct ComposeAutocompleteView_Previews: PreviewProvider {
static var previews: some View {
ComposeAutocompleteView(autocompleteState: .mention("shadowfacts"))
}
}

View File

@ -9,7 +9,7 @@
import SwiftUI import SwiftUI
struct ComposeAvatarImageView: View { struct ComposeAvatarImageView: View {
let url: URL let url: URL?
@State var request: ImageCache.Request? = nil @State var request: ImageCache.Request? = nil
@State var avatarImage: UIImage? = nil @State var avatarImage: UIImage? = nil
@ObservedObject var preferences = Preferences.shared @ObservedObject var preferences = Preferences.shared
@ -17,34 +17,43 @@ struct ComposeAvatarImageView: View {
var body: some View { var body: some View {
image image
.resizable() .resizable()
.frame(width: 50, height: 50) .conditionally(url != nil) {
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50) $0.onAppear(perform: self.loadImage)
.onAppear(perform: self.loadImage) }
.onDisappear(perform: self.cancelRequest) .onDisappear(perform: self.cancelRequest)
} }
private var image: Image { private var image: Image {
if let avatarImage = avatarImage { if let avatarImage = avatarImage {
return Image(uiImage: avatarImage) return Image(uiImage: avatarImage).renderingMode(.original)
} else { } else {
let imageName: String return placeholderImage
switch preferences.avatarStyle {
case .circle:
imageName = "person.crop.circle"
case .roundRect:
imageName = "person.crop.square"
}
return Image(systemName: imageName)
} }
} }
private var placeholderImage: Image {
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() { private func loadImage() {
guard let url = url else { return }
request = ImageCache.avatars.get(url) { (data) in request = ImageCache.avatars.get(url) { (data) in
DispatchQueue.main.async { if let data = data, let image = UIImage(data: data) {
self.request = nil DispatchQueue.main.async {
if let data = data, let image = UIImage(data: data) { self.request = nil
self.avatarImage = image self.avatarImage = image
} }
} else {
DispatchQueue.main.async {
self.request = nil
}
} }
} }
} }

View File

@ -0,0 +1,165 @@
//
// ComposeContentWarningTextField.swift
// Tusker
//
// Created by Shadowfacts on 10/12/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
struct ComposeContentWarningTextField: UIViewRepresentable {
typealias UIViewType = UITextField
@Binding var text: String
@EnvironmentObject private var uiState: ComposeUIState
func makeUIView(context: Context) -> UITextField {
let view = UITextField()
view.placeholder = "Write your warning here"
view.borderStyle = .roundedRect
view.delegate = context.coordinator
view.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .editingChanged)
context.coordinator.textField = view
context.coordinator.uiState = uiState
context.coordinator.text = $text
return view
}
func updateUIView(_ uiView: UITextField, context: Context) {
uiView.text = text
}
func makeCoordinator() -> Coordinator {
return Coordinator()
}
class Coordinator: NSObject, UITextFieldDelegate, ComposeAutocompleteHandler {
weak var textField: UITextField?
var text: Binding<String>!
var uiState: ComposeUIState!
@objc func didChange(_ textField: UITextField) {
text.wrappedValue = textField.text ?? ""
}
func textFieldDidBeginEditing(_ textField: UITextField) {
uiState.autocompleteHandler = self
updateAutocompleteState(textField: textField)
}
func textFieldDidEndEditing(_ textField: UITextField) {
updateAutocompleteState(textField: textField)
}
func textFieldDidChangeSelection(_ textField: UITextField) {
updateAutocompleteState(textField: textField)
}
func autocomplete(with string: String) {
guard let textField = textField,
let text = textField.text,
let selectedRange = textField.selectedTextRange,
let lastWordStartIndex = findAutocompleteLastWord(textField: textField) else {
return
}
let distanceToEnd = textField.offset(from: selectedRange.start, to: textField.endOfDocument)
let selectedRangeStartUTF16 = textField.offset(from: textField.beginningOfDocument, to: selectedRange.start)
let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
let insertSpace: Bool
if distanceToEnd > 0 {
let charAfterCursor = text[characterBeforeCursorIndex]
insertSpace = charAfterCursor != " " && charAfterCursor != "\n"
} else {
insertSpace = true
}
let string = insertSpace ? string + " " : string
textField.text!.replaceSubrange(lastWordStartIndex..<characterBeforeCursorIndex, with: string)
self.didChange(textField)
self.updateAutocompleteState(textField: textField)
// keep the cursor at the same position in the text, immediately after what was inserted
// if we inserted a space, move the cursor 1 farther so it's immediately after the pre-existing space
let insertSpaceOffset = insertSpace ? 0 : 1
let newCursorPosition = textField.position(from: textField.endOfDocument, offset: -distanceToEnd + insertSpaceOffset)!
textField.selectedTextRange = textField.textRange(from: newCursorPosition, to: newCursorPosition)
}
private func updateAutocompleteState(textField: UITextField) {
guard let selectedRange = textField.selectedTextRange,
let text = textField.text,
let lastWordStartIndex = findAutocompleteLastWord(textField: textField) else {
uiState.autocompleteState = nil
return
}
if lastWordStartIndex > text.startIndex {
let c = text[text.index(before: lastWordStartIndex)]
if isPermittedForAutocomplete(c) || c == ":" {
uiState.autocompleteState = nil
return
}
}
let selectedRangeStartUTF16 = textField.offset(from: textField.beginningOfDocument, to: selectedRange.start)
let cursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
if lastWordStartIndex >= text.startIndex {
let lastWord = text[lastWordStartIndex..<cursorIndex]
let exceptFirst = lastWord[lastWord.index(after: lastWord.startIndex)...]
if lastWord.first == ":" {
uiState.autocompleteState = .emoji(String(exceptFirst))
} else {
uiState.autocompleteState = nil
}
} else {
uiState.autocompleteState = nil
}
}
private func isPermittedForAutocomplete(_ c: Character) -> Bool {
return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_"
}
private func findAutocompleteLastWord(textField: UITextField) -> String.Index? {
guard textField.isFirstResponder,
let selectedRange = textField.selectedTextRange,
selectedRange.isEmpty,
let text = textField.text,
!text.isEmpty else {
return nil
}
let selectedRangeStartUTF16 = textField.offset(from: textField.beginningOfDocument, to: selectedRange.start)
let cursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
var lastWordStartIndex = text.index(before: cursorIndex)
while true {
let c = text[lastWordStartIndex]
if !isPermittedForAutocomplete(c) {
break
}
if lastWordStartIndex > text.startIndex {
lastWordStartIndex = text.index(before: lastWordStartIndex)
} else {
break
}
}
return lastWordStartIndex
}
}
}

View File

@ -11,25 +11,33 @@ import Pachyderm
struct ComposeCurrentAccount: View { struct ComposeCurrentAccount: View {
@EnvironmentObject var mastodonController: MastodonController @EnvironmentObject var mastodonController: MastodonController
@ObservedObject private var preferences = Preferences.shared
var account: Account { var account: Account? {
mastodonController.account! mastodonController.account
} }
var body: some View { var body: some View {
HStack(alignment: .top) { HStack(alignment: .top) {
ComposeAvatarImageView(url: account.avatar) ComposeAvatarImageView(url: account?.avatar)
.accessibility(label: Text("\(account.displayName) avatar")) .frame(width: 50, height: 50)
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
.accessibility(label: Text(account != nil ? "\(account!.displayName) avatar" : "Avatar"))
VStack(alignment: .leading) { if let id = account?.id,
AccountDisplayNameLabel(account: mastodonController.persistentContainer.account(for: account.id)!, fontSize: 20) let account = mastodonController.persistentContainer.account(for: id) {
.lineLimit(1) VStack(alignment: .leading) {
AccountDisplayNameLabel(account: account, fontSize: 20)
Text(verbatim: "@\(account.acct)") .lineLimit(1)
.font(.system(size: 17, weight: .light))
.foregroundColor(.secondary) Text(verbatim: "@\(account.acct)")
.lineLimit(1) .font(.system(size: 17, weight: .light))
.foregroundColor(.secondary)
.lineLimit(1)
}
} }
Spacer()
} }
} }
} }

View File

@ -58,15 +58,11 @@ class ComposeDrawingViewController: UIViewController {
canvasView.drawing = initialDrawing canvasView.drawing = initialDrawing
} }
canvasView.delegate = self canvasView.delegate = self
#if SDK_IOS_14
if #available(iOS 14.0, *) { if #available(iOS 14.0, *) {
canvasView.drawingPolicy = .anyInput canvasView.drawingPolicy = .anyInput
} else { } else {
canvasView.allowsFingerDrawing = true canvasView.allowsFingerDrawing = true
} }
#else
canvasView.allowsFingerDrawing = true
#endif
canvasView.minimumZoomScale = 0.5 canvasView.minimumZoomScale = 0.5
canvasView.maximumZoomScale = 2 canvasView.maximumZoomScale = 2
canvasView.backgroundColor = .systemBackground canvasView.backgroundColor = .systemBackground

View File

@ -164,7 +164,9 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! CGRect let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
// temporarily reset add'l safe area insets so we can access the default inset // temporarily reset add'l safe area insets so we can access the default inset
additionalSafeAreaInsets = .zero additionalSafeAreaInsets = .zero
keyboardHeight = frame.height - view.safeAreaInsets.bottom - accessoryView.frame.height // there are a few extra points that come from somewhere, it seems to be four
// and without it, the autocomplete suggestions are cut off :S
keyboardHeight = frame.height - view.safeAreaInsets.bottom - accessoryView.frame.height + 4
updateAdditionalSafeAreaInsets() updateAdditionalSafeAreaInsets()
} }
} }

View File

@ -15,6 +15,8 @@ struct ComposeReplyView: View {
@State private var contentHeight: CGFloat? @State private var contentHeight: CGFloat?
@ObservedObject private var preferences = Preferences.shared
private let horizSpacing: CGFloat = 8 private let horizSpacing: CGFloat = 8
var body: some View { var body: some View {
@ -55,6 +57,8 @@ struct ComposeReplyView: View {
scrollOffset += stackPadding scrollOffset += stackPadding
let offset = min(max(scrollOffset, 0), geometry.size.height - 50 - stackPadding) let offset = min(max(scrollOffset, 0), geometry.size.height - 50 - stackPadding)
return ComposeAvatarImageView(url: status.account.avatar) return ComposeAvatarImageView(url: status.account.avatar)
.frame(width: 50, height: 50)
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
.offset(x: 0, y: offset) .offset(x: 0, y: offset)
} }

View File

@ -27,13 +27,7 @@ struct ComposeTextView: View {
var body: some View { var body: some View {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
WrappedTextView( Color(backgroundColor)
text: $text,
textDidChange: self.textDidChange,
backgroundColor: backgroundColor,
font: .systemFont(ofSize: fontSize)
)
.frame(height: height ?? minHeight)
if text.isEmpty, let placeholder = placeholder { if text.isEmpty, let placeholder = placeholder {
placeholder placeholder
@ -41,6 +35,13 @@ struct ComposeTextView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
.offset(x: 4, y: 8) .offset(x: 4, y: 8)
} }
WrappedTextView(
text: $text,
textDidChange: self.textDidChange,
font: .systemFont(ofSize: fontSize)
)
.frame(height: height ?? minHeight)
} }
} }
@ -73,14 +74,13 @@ struct WrappedTextView: UIViewRepresentable {
@Binding var text: String @Binding var text: String
var textDidChange: ((UITextView) -> Void)? var textDidChange: ((UITextView) -> Void)?
var backgroundColor = UIColor.secondarySystemBackground
var font = UIFont.systemFont(ofSize: 20) var font = UIFont.systemFont(ofSize: 20)
func makeUIView(context: Context) -> UITextView { func makeUIView(context: Context) -> UITextView {
let textView = UITextView() let textView = UITextView()
textView.delegate = context.coordinator textView.delegate = context.coordinator
textView.isEditable = true textView.isEditable = true
textView.backgroundColor = backgroundColor textView.backgroundColor = .clear
textView.font = font textView.font = font
textView.textContainer.lineBreakMode = .byWordWrapping textView.textContainer.lineBreakMode = .byWordWrapping
return textView return textView

View File

@ -27,9 +27,12 @@ class ComposeUIState: ObservableObject {
@Published var draft: Draft @Published var draft: Draft
@Published var isShowingSaveDraftSheet = false @Published var isShowingSaveDraftSheet = false
@Published var attachmentsMissingDescriptions = Set<UUID>() @Published var attachmentsMissingDescriptions = Set<UUID>()
@Published var autocompleteState: AutocompleteState? = nil
var composeDrawingMode: ComposeDrawingMode? var composeDrawingMode: ComposeDrawingMode?
weak var autocompleteHandler: ComposeAutocompleteHandler?
init(draft: Draft) { init(draft: Draft) {
self.draft = draft self.draft = draft
} }
@ -42,3 +45,15 @@ extension ComposeUIState {
case edit(id: UUID) case edit(id: UUID)
} }
} }
extension ComposeUIState {
enum AutocompleteState {
case mention(String)
case emoji(String)
case hashtag(String)
}
}
protocol ComposeAutocompleteHandler: class {
func autocomplete(with string: String)
}

View File

@ -28,7 +28,7 @@ struct ComposeView: View {
} }
var charactersRemaining: Int { var charactersRemaining: Int {
let limit = mastodonController.instance.maxStatusCharacters ?? 500 let limit = mastodonController.instance?.maxStatusCharacters ?? 500
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0 let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
return limit - (cwCount + CharacterCounter.count(text: draft.text)) return limit - (cwCount + CharacterCounter.count(text: draft.text))
} }
@ -65,6 +65,8 @@ struct ComposeView: View {
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149 // can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
WrappedProgressView(value: postProgress, total: postTotalProgress) WrappedProgressView(value: postProgress, total: postTotalProgress)
autocompleteSuggestions
} }
.onAppear(perform: self.didAppear) .onAppear(perform: self.didAppear)
.navigationBarTitle("Compose") .navigationBarTitle("Compose")
@ -78,6 +80,28 @@ struct ComposeView: View {
} }
} }
@ViewBuilder
var autocompleteSuggestions: some View {
// on iOS 13, the transition causes SwiftUI to hang on the main thread when the view appears, so it's disabled
if #available(iOS 14.0, *) {
VStack(spacing: 0) {
Spacer()
if let state = uiState.autocompleteState {
ComposeAutocompleteView(autocompleteState: state)
}
}
.transition(.move(edge: .bottom))
.animation(.default)
} else {
VStack(spacing: 0) {
Spacer()
if let state = uiState.autocompleteState {
ComposeAutocompleteView(autocompleteState: state)
}
}
}
}
func mainStack(outerMinY: CGFloat) -> some View { func mainStack(outerMinY: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
if let id = draft.inReplyToID, if let id = draft.inReplyToID,
@ -92,8 +116,7 @@ struct ComposeView: View {
header header
if draft.contentWarningEnabled { if draft.contentWarningEnabled {
TextField("Write your warning here", text: $draft.contentWarning) ComposeContentWarningTextField(text: $draft.contentWarning)
.textFieldStyle(RoundedBorderTextFieldStyle())
} }
MainComposeTextView( MainComposeTextView(
@ -108,6 +131,7 @@ struct ComposeView: View {
.padding([.top, .bottom], -8) .padding([.top, .bottom], -8)
} }
.padding(stackPadding) .padding(stackPadding)
.padding(.bottom, uiState.autocompleteState != nil ? 46 : nil)
} }
private var header: some View { private var header: some View {
@ -197,7 +221,7 @@ struct ComposeView: View {
self.isPosting = false self.isPosting = false
case let .success(uploadedAttachments): case let .success(uploadedAttachments):
let request = Client.createStatus(text: draft.text, let request = Client.createStatus(text: draft.textForPosting,
contentType: Preferences.shared.statusContentType, contentType: Preferences.shared.statusContentType,
inReplyTo: draft.inReplyToID, inReplyTo: draft.inReplyToID,
media: uploadedAttachments, media: uploadedAttachments,

View File

@ -0,0 +1,64 @@
//
// EmojiCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 10/12/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class EmojiCollectionViewCell: UICollectionViewCell {
private var emojiImageView: UIImageView!
private var emojiNameLabel: UILabel!
private var currentEmojiShortcode: String?
private var imageRequest: ImageCache.Request?
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
emojiImageView = UIImageView()
emojiImageView.translatesAutoresizingMaskIntoConstraints = false
emojiImageView.contentMode = .scaleAspectFit
addSubview(emojiImageView)
NSLayoutConstraint.activate([
emojiImageView.leadingAnchor.constraint(equalTo: leadingAnchor),
emojiImageView.trailingAnchor.constraint(equalTo: trailingAnchor),
emojiImageView.topAnchor.constraint(equalTo: topAnchor),
emojiImageView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
func updateUI(emoji: Emoji) {
currentEmojiShortcode = emoji.shortcode
imageRequest = ImageCache.emojis.get(emoji.url) { [weak self] (data) in
if let data = data, let image = UIImage(data: data) {
DispatchQueue.main.async { [weak self] in
guard let self = self, self.currentEmojiShortcode == emoji.shortcode else { return }
self.emojiImageView.image = image
}
}
}
}
override func prepareForReuse() {
super.prepareForReuse()
imageRequest?.cancel()
}
}

View File

@ -0,0 +1,128 @@
//
// EmojiPickerCollectionViewController.swift
// Tusker
//
// Created by Shadowfacts on 10/12/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
private let reuseIdentifier = "EmojiCell"
protocol EmojiPickerCollectionViewControllerDelegate: class {
func selectedEmoji(_ emoji: Emoji)
}
// It would be nice to replace this with a LazyVGrid when the deployment target is bumped to 14.0
class EmojiPickerCollectionViewController: UICollectionViewController {
weak var delegate: EmojiPickerCollectionViewControllerDelegate?
private weak var mastodonController: MastodonController!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
var searchQuery: String = "" {
didSet {
guard let emojis = mastodonController.customEmojis else { return }
let snapshot = createFilteredSnapshot(emojis: emojis)
DispatchQueue.main.async {
self.dataSource.apply(snapshot)
}
}
}
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
let itemWidth = NSCollectionLayoutDimension.fractionalWidth(1.0 / 10)
let itemSize = NSCollectionLayoutSize(widthDimension: itemWidth, heightDimension: itemWidth)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: itemWidth)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
group.interItemSpacing = .fixed(4)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 4
let layout = UICollectionViewCompositionalLayout(section: section)
super.init(collectionViewLayout: layout)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
// use negative indicator insets to bring the indicators back to the edge of the containing view
// using collectionView.contentInset doesn't work the compositional layout ignores the inset when calculating fractional widths
collectionView.scrollIndicatorInsets = UIEdgeInsets(top: 0, left: -8, bottom: 0, right: -8)
collectionView.contentInset = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
collectionView.backgroundColor = .clear
collectionView.register(EmojiCollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier)
dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { (collectionView, indexPath, item) -> UICollectionViewCell? in
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! EmojiCollectionViewCell
cell.updateUI(emoji: item.emoji)
return cell
}
mastodonController.getCustomEmojis { (emojis) in
DispatchQueue.main.async {
self.dataSource.apply(self.createFilteredSnapshot(emojis: emojis))
}
}
}
private func createFilteredSnapshot(emojis: [Emoji]) -> NSDiffableDataSourceSnapshot<Section, Item> {
let items: [Item]
if searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
items = emojis.map { Item(emoji: $0) }
} else {
items = emojis
.map { ($0, FuzzyMatcher.match(pattern: searchQuery, str: $0.shortcode)) }
.filter(\.1.matched)
.sorted { $0.1.score > $1.1.score }
.map { Item(emoji: $0.0) }
}
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.emojis])
snapshot.appendItems(items)
return snapshot
}
// MARK: UICollectionViewDelegate
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
delegate?.selectedEmoji(item.emoji)
}
}
extension EmojiPickerCollectionViewController {
enum Section {
case emojis
}
struct Item: Hashable, Equatable {
let emoji: Emoji
func hash(into hasher: inout Hasher) {
hasher.combine(emoji.shortcode)
}
static func ==(lhs: Item, rhs: Item) -> Bool {
lhs.emoji.shortcode == rhs.emoji.shortcode
}
}
}

View File

@ -0,0 +1,46 @@
//
// EmojiPickerWrapper.swift
// Tusker
//
// Created by Shadowfacts on 10/14/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
import Pachyderm
struct EmojiPickerWrapper: UIViewControllerRepresentable {
typealias UIViewControllerType = EmojiPickerCollectionViewController
let searchQuery: String
@EnvironmentObject private var mastodonController: MastodonController
@EnvironmentObject private var uiState: ComposeUIState
func makeUIViewController(context: Context) -> EmojiPickerCollectionViewController {
let vc = EmojiPickerCollectionViewController(mastodonController: mastodonController)
vc.delegate = context.coordinator
return vc
}
func updateUIViewController(_ uiViewController: EmojiPickerCollectionViewController, context: Context) {
uiViewController.searchQuery = searchQuery
}
func makeCoordinator() -> Coordinator {
return Coordinator(uiState: uiState)
}
class Coordinator: EmojiPickerCollectionViewControllerDelegate {
let uiState: ComposeUIState
init(uiState: ComposeUIState) {
self.uiState = uiState
}
func selectedEmoji(_ emoji: Emoji) {
uiState.autocompleteHandler?.autocomplete(with: ":\(emoji.shortcode):")
uiState.autocompleteState = nil
}
}
}

View File

@ -20,14 +20,7 @@ struct MainComposeTextView: View {
var body: some View { var body: some View {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
MainComposeWrappedTextView( Color(UIColor.secondarySystemBackground)
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 { if draft.text.isEmpty {
placeholder placeholder
@ -35,7 +28,17 @@ struct MainComposeTextView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
.offset(x: 4, y: 8) .offset(x: 4, y: 8)
} }
}.onAppear {
MainComposeWrappedTextView(
text: $draft.text,
visibility: draft.visibility,
becomeFirstResponder: $becomeFirstResponder
) { (textView) in
self.height = max(textView.contentSize.height, minHeight)
}
}
.frame(height: height ?? minHeight)
.onAppear {
if !hasFirstAppeared { if !hasFirstAppeared {
hasFirstAppeared = true hasFirstAppeared = true
becomeFirstResponder = true becomeFirstResponder = true
@ -59,11 +62,13 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
let textView = UITextView() let textView = UITextView()
textView.delegate = context.coordinator textView.delegate = context.coordinator
textView.isEditable = true textView.isEditable = true
textView.backgroundColor = .secondarySystemBackground textView.backgroundColor = .clear
textView.font = .systemFont(ofSize: 20) textView.font = .systemFont(ofSize: 20)
textView.textContainer.lineBreakMode = .byWordWrapping textView.textContainer.lineBreakMode = .byWordWrapping
context.coordinator.textView = textView context.coordinator.textView = textView
uiState.autocompleteHandler = context.coordinator
let visibilityAction: Selector? let visibilityAction: Selector?
if #available(iOS 14.0, *) { if #available(iOS 14.0, *) {
visibilityAction = nil visibilityAction = nil
@ -147,27 +152,25 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
context.coordinator.didChange = textDidChange context.coordinator.didChange = textDidChange
context.coordinator.uiState = uiState context.coordinator.uiState = uiState
if becomeFirstResponder { // wait until the next runloop iteration so that SwiftUI view updates have finished and
DispatchQueue.main.async { // the text view knows its new content size
DispatchQueue.main.async {
self.textDidChange(uiView)
if becomeFirstResponder {
// calling becomeFirstResponder during the SwiftUI update causes a crash on iOS 13 // calling becomeFirstResponder during the SwiftUI update causes a crash on iOS 13
uiView.becomeFirstResponder() uiView.becomeFirstResponder()
// can't update @State vars during the SwiftUI update // can't update @State vars during the SwiftUI update
becomeFirstResponder = false 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 { func makeCoordinator() -> Coordinator {
return Coordinator(text: $text, uiState: uiState, didChange: textDidChange) return Coordinator(text: $text, uiState: uiState, didChange: textDidChange)
} }
class Coordinator: NSObject, UITextViewDelegate { class Coordinator: NSObject, UITextViewDelegate, ComposeAutocompleteHandler {
weak var textView: UITextView? weak var textView: UITextView?
var text: Binding<String> var text: Binding<String>
var didChange: (UITextView) -> Void var didChange: (UITextView) -> Void
@ -213,5 +216,146 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
@objc func keyboardDidHide(_ notification: Foundation.Notification) { @objc func keyboardDidHide(_ notification: Foundation.Notification) {
uiState.delegate?.keyboardDidHide(accessoryView: textView!.inputAccessoryView!, notification: notification) uiState.delegate?.keyboardDidHide(accessoryView: textView!.inputAccessoryView!, notification: notification)
} }
func textViewDidBeginEditing(_ textView: UITextView) {
uiState.autocompleteHandler = self
updateAutocompleteState()
}
func textViewDidEndEditing(_ textView: UITextView) {
updateAutocompleteState()
}
func textViewDidChangeSelection(_ textView: UITextView) {
updateAutocompleteState()
}
func autocomplete(with string: String) {
guard let textView = textView,
let text = textView.text,
let (lastWordStartIndex, _) = findAutocompleteLastWord() else {
return
}
let distanceToEnd = text.utf16.count - textView.selectedRange.upperBound
let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound)
let insertSpace: Bool
if distanceToEnd > 0 {
let charAfterCursor = text[characterBeforeCursorIndex]
insertSpace = charAfterCursor != " " && charAfterCursor != "\n"
} else {
insertSpace = true
}
let string = insertSpace ? string + " " : string
textView.text.replaceSubrange(lastWordStartIndex..<characterBeforeCursorIndex, with: string)
self.textViewDidChange(textView)
self.updateAutocompleteState()
// keep the cursor at the same position in the text, immediately after what was inserted
// if we inserted a space, move the cursor 1 farther so it's immediately after the pre-existing space
let insertSpaceOffset = insertSpace ? 0 : 1
textView.selectedRange = NSRange(location: textView.text.utf16.count - distanceToEnd + insertSpaceOffset, length: 0)
}
private func updateAutocompleteState() {
guard let textView = textView,
let text = textView.text,
let (lastWordStartIndex, foundFirstAtSign) = findAutocompleteLastWord() else {
uiState.autocompleteState = nil
return
}
let triggerChars: [Character] = ["@", ":", "#"]
if lastWordStartIndex > text.startIndex {
// if the character before the "word" beginning is a valid part of a "word",
// we aren't able to autocomplete
let c = text[text.index(before: lastWordStartIndex)]
if isPermittedForAutocomplete(c) || triggerChars.contains(c) {
uiState.autocompleteState = nil
return
}
}
let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound)
if lastWordStartIndex >= text.startIndex {
let lastWord = text[lastWordStartIndex..<characterBeforeCursorIndex]
let exceptFirst = lastWord[lastWord.index(after: lastWord.startIndex)...]
// periods are only allowed in mentions in the domain part
if lastWord.contains(".") {
if lastWord.first == "@" && foundFirstAtSign {
uiState.autocompleteState = .mention(String(exceptFirst))
} else {
uiState.autocompleteState = nil
}
return
}
switch lastWord.first {
case "@":
uiState.autocompleteState = .mention(String(exceptFirst))
case ":":
uiState.autocompleteState = .emoji(String(exceptFirst))
case "#":
uiState.autocompleteState = .hashtag(String(exceptFirst))
default:
uiState.autocompleteState = nil
}
} else {
uiState.autocompleteState = nil
}
}
private func isPermittedForAutocomplete(_ c: Character) -> Bool {
return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_"
}
private func findAutocompleteLastWord() -> (index: String.Index, foundFirstAtSign: Bool)? {
guard let textView = textView,
textView.isFirstResponder,
textView.selectedRange.length == 0,
textView.selectedRange.upperBound > 0,
let text = textView.text,
text.count > 0 else {
return nil
}
let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound)
var lastWordStartIndex = text.index(before: characterBeforeCursorIndex)
var foundFirstAtSign = false
while true {
let c = text[lastWordStartIndex]
if !isPermittedForAutocomplete(c) {
if foundFirstAtSign {
if c != "@" {
// move the index forward by 1, so that the first char of the substring is the 1st @ instead of whatever comes before it
lastWordStartIndex = text.index(after: lastWordStartIndex)
}
break
} else {
if c == "@" {
foundFirstAtSign = true
} else if c != "." {
// periods are allowed for domain names in mentions
break
}
}
}
if lastWordStartIndex > text.startIndex {
lastWordStartIndex = text.index(before: lastWordStartIndex)
} else {
break
}
}
return (lastWordStartIndex, foundFirstAtSign)
}
} }
} }

View File

@ -51,6 +51,8 @@ class ConversationTableViewController: EnhancedTableViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = NSLocalizedString("Conversation", comment: "conversation screen title")
tableView.delegate = self tableView.delegate = self
tableView.dataSource = self tableView.dataSource = self

View File

@ -13,11 +13,18 @@ class AddSavedHashtagViewController: SearchResultsViewController {
var searchController: UISearchController! var searchController: UISearchController!
init(mastodonController: MastodonController) {
super.init(mastodonController: mastodonController, resultTypes: [.hashtags])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
delegate = self delegate = self
onlySections = [.hashtags]
searchController = UISearchController(searchResultsController: nil) searchController = UISearchController(searchResultsController: nil)
searchController.obscuresBackgroundDuringPresentation = false searchController.obscuresBackgroundDuringPresentation = false

View File

@ -133,9 +133,13 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
centerImage() centerImage()
// todo: does this need to be in viewDidLayoutSubviews? // todo: does this need to be in viewDidLayoutSubviews?
// on iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max, the top safe area inset is 44pts let notchedDeviceTopInsets: [CGFloat] = [
// on iPhone XR, 11, the top inset is 48pts 44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max
if view.safeAreaInsets.top == 44 || view.safeAreaInsets.top == 48 { 48, // iPhone XR, 11
47, // iPhone 12, 12 Pro, 12 Pro Max
50, // iPhone 12 mini
]
if notchedDeviceTopInsets.contains(view.safeAreaInsets.top) {
let notchWidth: CGFloat = 209 let notchWidth: CGFloat = 209
let earWidth = (view.bounds.width - notchWidth) / 2 let earWidth = (view.bounds.width - notchWidth) / 2
let offset = (earWidth - shareButton.bounds.width) / 2 let offset = (earWidth - shareButton.bounds.width) / 2

View File

@ -20,9 +20,7 @@ class LargeImageInteractionController: UIPercentDrivenInteractiveTransition {
super.init() super.init()
self.viewController = viewController self.viewController = viewController
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handleGesture(_:))) let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handleGesture(_:)))
if #available(iOS 13.4, *) { panRecognizer.allowedScrollTypesMask = .all
panRecognizer.allowedScrollTypesMask = .all
}
viewController.view.addGestureRecognizer(panRecognizer) viewController.view.addGestureRecognizer(panRecognizer)
} }

View File

@ -52,9 +52,8 @@ class EditListAccountsViewController: EnhancedTableViewController {
}) })
dataSource.editListAccountsController = self dataSource.editListAccountsController = self
searchResultsController = SearchResultsViewController(mastodonController: mastodonController) searchResultsController = SearchResultsViewController(mastodonController: mastodonController, resultTypes: [.accounts])
searchResultsController.delegate = self searchResultsController.delegate = self
searchResultsController.onlySections = [.accounts]
searchController = UISearchController(searchResultsController: searchResultsController) searchController = UISearchController(searchResultsController: searchResultsController)
searchController.hidesNavigationBarDuringPresentation = false searchController.hidesNavigationBarDuringPresentation = false
searchController.searchResultsUpdater = searchResultsController searchController.searchResultsUpdater = searchResultsController

View File

@ -9,7 +9,6 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
#if SDK_IOS_14
@available(iOS 14.0, *) @available(iOS 14.0, *)
protocol MainSidebarViewControllerDelegate: class { protocol MainSidebarViewControllerDelegate: class {
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController) func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
@ -380,4 +379,3 @@ extension MainSidebarViewController: InstanceTimelineViewControllerDelegate {
dismiss(animated: true) dismiss(animated: true)
} }
} }
#endif

View File

@ -8,7 +8,6 @@
import UIKit import UIKit
#if SDK_IOS_14
@available(iOS 14.0, *) @available(iOS 14.0, *)
class MainSplitViewController: UISplitViewController { class MainSplitViewController: UISplitViewController {
@ -21,6 +20,14 @@ class MainSplitViewController: UISplitViewController {
private var tabBarViewController: MainTabBarViewController! private var tabBarViewController: MainTabBarViewController!
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
if UIDevice.current.userInterfaceIdiom == .phone {
return .portrait
} else {
return .all
}
}
init(mastodonController: MastodonController) { init(mastodonController: MastodonController) {
self.mastodonController = mastodonController self.mastodonController = mastodonController
@ -316,11 +323,15 @@ extension MainSplitViewController: TuskerRootViewController {
} }
func select(tab: MainTabBarViewController.Tab) { func select(tab: MainTabBarViewController.Tab) {
if tab == .compose { if traitCollection.horizontalSizeClass == .compact {
presentCompose() tabBarViewController?.select(tab: tab)
} else { } else {
select(item: .tab(tab)) if tab == .compose {
presentCompose()
} else {
select(item: .tab(tab))
sidebar.select(item: .tab(tab), animated: false)
}
} }
} }
} }
#endif

View File

@ -37,7 +37,21 @@ struct PreferencesView: View {
} }
} }
} }
}.onDelete { (indices: IndexSet) in
var indices = indices
var logoutFromCurrent = false
if let index = indices.first(where: { localData.accounts[$0] == localData.getMostRecentAccount() }) {
logoutFromCurrent = true
indices.remove(index)
}
localData.accounts.remove(atOffsets: indices)
if logoutFromCurrent {
self.logoutPressed()
}
} }
Button(action: { Button(action: {
NotificationCenter.default.post(name: .addAccount, object: nil) NotificationCenter.default.post(name: .addAccount, object: nil)
}) { }) {

View File

@ -7,6 +7,7 @@
// //
import UIKit import UIKit
import Pachyderm
class MyProfileViewController: ProfileViewController { class MyProfileViewController: ProfileViewController {
@ -21,19 +22,8 @@ class MyProfileViewController: ProfileViewController {
DispatchQueue.main.async { DispatchQueue.main.async {
self.accountID = account.id self.accountID = account.id
self.setAvatarTabBarImage(account: account)
} }
_ = ImageCache.avatars.get(account.avatar, completion: { [weak self] (data) in
guard let self = self, let data = data, let image = UIImage(data: data) else { return }
DispatchQueue.main.async {
let size = CGSize(width: 30, height: 30)
let tabBarImage = UIGraphicsImageRenderer(size: size).image { (_) in
image.draw(in: CGRect(origin: .zero, size: size))
}
let alwaysOriginalImage = tabBarImage.withRenderingMode(.alwaysOriginal)
self.tabBarItem.image = alwaysOriginalImage
}
})
} }
} }
@ -45,8 +35,38 @@ class MyProfileViewController: ProfileViewController {
super.viewDidLoad() super.viewDidLoad()
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Preferences", style: .plain, target: self, action: #selector(preferencesPressed)) navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Preferences", style: .plain, target: self, action: #selector(preferencesPressed))
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
} }
private func setAvatarTabBarImage<Account: AccountProtocol>(account: Account) {
_ = ImageCache.avatars.get(account.avatar, completion: { [weak self] (data) in
guard let self = self, let data = data, let image = UIImage(data: data) else { return }
DispatchQueue.main.async {
let size = CGSize(width: 30, height: 30)
let rect = CGRect(origin: .zero, size: size)
let tabBarImage = UIGraphicsImageRenderer(size: size).image { (_) in
let radius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
UIBezierPath(roundedRect: rect, cornerRadius: radius).addClip()
image.draw(in: rect)
}
let alwaysOriginalImage = tabBarImage.withRenderingMode(.alwaysOriginal)
self.tabBarItem.image = alwaysOriginalImage
}
})
}
@objc private func preferencesChanged() {
guard let id = mastodonController.account?.id,
let account = mastodonController.persistentContainer.account(for: id) else {
return
}
setAvatarTabBarImage(account: account)
}
// MARK: - Interaction
@objc func preferencesPressed() { @objc func preferencesPressed() {
present(PreferencesNavigationController(mastodonController: mastodonController), animated: true) present(PreferencesNavigationController(mastodonController: mastodonController), animated: true)
} }

View File

@ -14,11 +14,17 @@ class ProfileViewController: UIPageViewController {
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
// todo: does this still need to be settable? // This property is optional because MyProfileViewController may not have the user's account ID
var accountID: String! { // when first constructed. It should never be set to nil.
var accountID: String? {
willSet {
if newValue == nil {
fatalError("Do not set ProfileViewController.accountID to nil")
}
}
didSet { didSet {
updateAccountUI()
pageControllers.forEach { $0.accountID = accountID } pageControllers.forEach { $0.accountID = accountID }
loadAccount()
} }
} }
@ -50,7 +56,9 @@ class ProfileViewController: UIPageViewController {
} }
deinit { deinit {
mastodonController.persistentContainer.account(for: accountID)?.decrementReferenceCount() if let accountID = accountID {
mastodonController.persistentContainer.account(for: accountID)?.decrementReferenceCount()
}
} }
override func viewDidLoad() { override func viewDidLoad() {
@ -84,8 +92,12 @@ class ProfileViewController: UIPageViewController {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] (_) in self?.updateAccountUI() } .sink { [weak self] (_) in self?.updateAccountUI() }
loadAccount()
}
private func loadAccount() {
guard let accountID = accountID else { return }
if mastodonController.persistentContainer.account(for: accountID) != nil { if mastodonController.persistentContainer.account(for: accountID) != nil {
headerView.updateUI(for: accountID)
updateAccountUI() updateAccountUI()
} else { } else {
let req = Client.getAccount(id: accountID) let req = Client.getAccount(id: accountID)
@ -95,10 +107,6 @@ class ProfileViewController: UIPageViewController {
self.mastodonController.persistentContainer.addOrUpdate(account: account, incrementReferenceCount: true) { (account) in self.mastodonController.persistentContainer.addOrUpdate(account: account, incrementReferenceCount: true) { (account) in
DispatchQueue.main.async { DispatchQueue.main.async {
self.updateAccountUI() self.updateAccountUI()
self.headerView.updateUI(for: self.accountID)
self.pageControllers.forEach {
$0.updateUI(account: account)
}
} }
} }
} }
@ -106,8 +114,17 @@ class ProfileViewController: UIPageViewController {
} }
private func updateAccountUI() { private func updateAccountUI() {
guard let account = mastodonController.persistentContainer.account(for: accountID) else { return } guard let accountID = accountID,
let account = mastodonController.persistentContainer.account(for: accountID) else {
return
}
// Optionally invoke updateUI on headerView because viewDidLoad may not have been called yet
headerView?.updateUI(for: accountID)
navigationItem.title = account.displayNameWithoutCustomEmoji navigationItem.title = account.displayNameWithoutCustomEmoji
pageControllers.forEach {
$0.updateUI(account: account)
}
} }
private func selectPage(at index: Int, animated: Bool, completion: ((Bool) -> Void)? = nil) { private func selectPage(at index: Int, animated: Bool, completion: ((Bool) -> Void)? = nil) {
@ -177,13 +194,15 @@ class ProfileViewController: UIPageViewController {
// MARK: Interaction // MARK: Interaction
@objc private func composeMentioning() { @objc private func composeMentioning() {
if let account = mastodonController.persistentContainer.account(for: accountID) { if let accountID = accountID,
let account = mastodonController.persistentContainer.account(for: accountID) {
compose(mentioningAcct: account.acct) compose(mentioningAcct: account.acct)
} }
} }
private func composeDirectMentioning() { private func composeDirectMentioning() {
if let account = mastodonController.persistentContainer.account(for: accountID) { if let accountID = accountID,
let account = mastodonController.persistentContainer.account(for: accountID) {
let draft = mastodonController.createDraft(mentioningAcct: account.acct) let draft = mastodonController.createDraft(mentioningAcct: account.acct)
draft.visibility = .direct draft.visibility = .direct
compose(editing: draft) compose(editing: draft)

View File

@ -35,15 +35,17 @@ class SearchResultsViewController: EnhancedTableViewController {
var dataSource: UITableViewDiffableDataSource<Section, Item>! var dataSource: UITableViewDiffableDataSource<Section, Item>!
var activityIndicator: UIActivityIndicatorView! private var activityIndicator: UIActivityIndicatorView!
var onlySections: [Section] = Section.allCases /// Types of results to search for. `nil` means all results will be included.
var resultTypes: [SearchResultType]? = nil
let searchSubject = PassthroughSubject<String?, Never>() let searchSubject = PassthroughSubject<String?, Never>()
var currentQuery: String? var currentQuery: String?
init(mastodonController: MastodonController) { init(mastodonController: MastodonController, resultTypes: [SearchResultType]? = nil) {
self.mastodonController = mastodonController self.mastodonController = mastodonController
self.resultTypes = resultTypes
super.init(style: .grouped) super.init(style: .grouped)
@ -128,7 +130,7 @@ class SearchResultsViewController: EnhancedTableViewController {
activityIndicator.isHidden = false activityIndicator.isHidden = false
activityIndicator.startAnimating() activityIndicator.startAnimating()
let request = Client.search(query: query, resolve: true, limit: 10) let request = Client.search(query: query, types: resultTypes, resolve: true, limit: 10)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
guard case let .success(results, _) = response else { fatalError() } guard case let .success(results, _) = response else { fatalError() }
@ -157,16 +159,16 @@ class SearchResultsViewController: EnhancedTableViewController {
} }
} }
if self.onlySections.contains(.accounts) && !results.accounts.isEmpty { if !results.accounts.isEmpty {
snapshot.appendSections([.accounts]) snapshot.appendSections([.accounts])
snapshot.appendItems(results.accounts.map { .account($0.id) }, toSection: .accounts) snapshot.appendItems(results.accounts.map { .account($0.id) }, toSection: .accounts)
addAccounts(results.accounts) addAccounts(results.accounts)
} }
if self.onlySections.contains(.hashtags) && !results.hashtags.isEmpty { if !results.hashtags.isEmpty {
snapshot.appendSections([.hashtags]) snapshot.appendSections([.hashtags])
snapshot.appendItems(results.hashtags.map { .hashtag($0) }, toSection: .hashtags) snapshot.appendItems(results.hashtags.map { .hashtag($0) }, toSection: .hashtags)
} }
if self.onlySections.contains(.statuses) && !results.statuses.isEmpty { if !results.statuses.isEmpty {
snapshot.appendSections([.statuses]) snapshot.appendSections([.statuses])
snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses) snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses)
addStatuses(results.statuses) addStatuses(results.statuses)

View File

@ -36,11 +36,9 @@ class InteractivePushTransition: UIPercentDrivenInteractiveTransition {
interactivePushGestureRecognizer.require(toFail: navigationController.interactivePopGestureRecognizer!) interactivePushGestureRecognizer.require(toFail: navigationController.interactivePopGestureRecognizer!)
navigationController.view.addGestureRecognizer(interactivePushGestureRecognizer) navigationController.view.addGestureRecognizer(interactivePushGestureRecognizer)
if #available(iOS 13.4, *) { let trackpadGestureRecognizer = TrackpadScrollGestureRecognizer(target: self, action: #selector(handleSwipeForward(_:)))
let trackpadGestureRecognizer = TrackpadScrollGestureRecognizer(target: self, action: #selector(handleSwipeForward(_:))) trackpadGestureRecognizer.require(toFail: navigationController.interactivePopGestureRecognizer!)
trackpadGestureRecognizer.require(toFail: navigationController.interactivePopGestureRecognizer!) navigationController.view.addGestureRecognizer(trackpadGestureRecognizer)
navigationController.view.addGestureRecognizer(trackpadGestureRecognizer)
}
} }
@objc func handleSwipeForward(_ recognizer: UIPanGestureRecognizer) { @objc func handleSwipeForward(_ recognizer: UIPanGestureRecognizer) {

View File

@ -54,8 +54,6 @@ extension MenuPreviewProvider {
}), }),
] ]
// todo: handle pre-iOS 14
#if SDK_IOS_14
if accountID != mastodonController.account.id, if accountID != mastodonController.account.id,
#available(iOS 14.0, *) { #available(iOS 14.0, *) {
actionsSection.append(UIDeferredMenuElement({ (elementHandler) in actionsSection.append(UIDeferredMenuElement({ (elementHandler) in
@ -71,9 +69,15 @@ extension MenuPreviewProvider {
let following = relationship.following let following = relationship.following
DispatchQueue.main.async { DispatchQueue.main.async {
elementHandler([ elementHandler([
self.createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.minus", handler: { (_) in self.createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.plus", handler: { (_) in
let request = (following ? Account.unfollow : Account.follow)(accountID) let request = (following ? Account.unfollow : Account.follow)(accountID)
mastodonController.run(request) { (_) in mastodonController.run(request) { (response) in
switch response {
case .failure(_):
fatalError()
case let .success(relationship, _):
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
}
} }
}) })
]) ])
@ -82,7 +86,6 @@ extension MenuPreviewProvider {
} }
})) }))
} }
#endif
let shareSection = [ let shareSection = [
openInSafariAction(url: account.url), openInSafariAction(url: account.url),

View File

@ -47,8 +47,9 @@ enum AppShortcutItem: String, CaseIterable {
} }
let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first! let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first!
let window = scene.windows.first { $0.isKeyWindow }! let window = scene.windows.first { $0.isKeyWindow }!
let controller = window.rootViewController as! MainTabBarViewController if let controller = window.rootViewController as? TuskerRootViewController {
controller.select(tab: tab) controller.select(tab: tab)
}
} }
} }

View File

@ -7,16 +7,17 @@
// //
import SwiftUI import SwiftUI
import Pachyderm
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
struct AccountDisplayNameLabel: View { struct AccountDisplayNameLabel<Account: AccountProtocol>: View {
let account: AccountMO let account: Account
let fontSize: Int let fontSize: Int
@State var text: Text @State var text: Text
@State var emojiRequests = [ImageCache.Request]() @State var emojiRequests = [ImageCache.Request]()
init(account: AccountMO, fontSize: Int) { init(account: Account, fontSize: Int) {
self.account = account self.account = account
self.fontSize = fontSize self.fontSize = fontSize
self._text = State(initialValue: Text(verbatim: account.displayName)) self._text = State(initialValue: Text(verbatim: account.displayName))
@ -40,7 +41,7 @@ struct AccountDisplayNameLabel: View {
let matches = emojiRegex.matches(in: account.displayName, options: [], range: fullRange) let matches = emojiRegex.matches(in: account.displayName, options: [], range: fullRange)
guard !matches.isEmpty else { return } guard !matches.isEmpty else { return }
let emojiImages = CachedDictionary<Image>(name: "AcccountDisplayNameLabel Emoji Images") let emojiImages = MultiThreadDictionary<String, Image>(name: "AcccountDisplayNameLabel Emoji Images")
let group = DispatchGroup() let group = DispatchGroup()

View File

@ -12,7 +12,7 @@ import Gifu
import AVFoundation import AVFoundation
protocol AttachmentViewDelegate: class { protocol AttachmentViewDelegate: class {
func attachmentViewGallery(startingAt index: Int) -> UIViewController? func attachmentViewGallery(startingAt index: Int) -> GalleryViewController?
func attachmentViewPresent(_ vc: UIViewController, animated: Bool) func attachmentViewPresent(_ vc: UIViewController, animated: Bool)
} }

View File

@ -57,12 +57,12 @@ class AttachmentsContainerView: UIView {
attachmentViews.allObjects.forEach { $0.removeFromSuperview() } attachmentViews.allObjects.forEach { $0.removeFromSuperview() }
attachmentViews.removeAllObjects() attachmentViews.removeAllObjects()
moreView?.removeFromSuperview() moreView?.removeFromSuperview()
var accessibilityElements = [Any]()
if attachments.count > 0 { if attachments.count > 0 {
self.isHidden = false self.isHidden = false
var accessibilityElements = [Any]()
switch attachments.count { switch attachments.count {
case 1: case 1:
let attachmentView = createAttachmentView(index: 0, hSize: .full, vSize: .full) let attachmentView = createAttachmentView(index: 0, hSize: .full, vSize: .full)
@ -215,12 +215,15 @@ class AttachmentsContainerView: UIView {
accessibilityElements.append(topRight) accessibilityElements.append(topRight)
accessibilityElements.append(bottomLeft) accessibilityElements.append(bottomLeft)
accessibilityElements.append(moreView) accessibilityElements.append(moreView)
} }
self.accessibilityElements = accessibilityElements
} else { } else {
self.isHidden = true self.isHidden = true
} }
// Make sure accessibilityElements is set every time the UI is updated, otherwise it holds
// on to strong references to the old set of attachment views
self.accessibilityElements = accessibilityElements
contentHidden = Preferences.shared.blurAllMedia || status.sensitive contentHidden = Preferences.shared.blurAllMedia || status.sensitive
} }

View File

@ -0,0 +1,83 @@
//
// BaseEmojiLabel.swift
// Tusker
//
// Created by Shadowfacts on 10/18/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
protocol BaseEmojiLabel: class {
var emojiIdentifier: String? { get set }
var emojiRequests: [ImageCache.Request] { get set }
var emojiFont: UIFont { get }
var emojiTextColor: UIColor { get }
}
extension BaseEmojiLabel {
func replaceEmojis(in string: String, emojis: [Emoji], identifier: String, completion: @escaping (NSAttributedString) -> Void) {
let matches = emojiRegex.matches(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count))
guard !matches.isEmpty else {
completion(NSAttributedString(string: string))
return
}
let emojiImages = MultiThreadDictionary<String, UIImage>(name: "BaseEmojiLabel Emoji Images")
var foundEmojis = false
let group = DispatchGroup()
for emoji in emojis {
// only make requests for emojis that are present in the text to avoid making unnecessary network requests
guard matches.contains(where: { (match) in
let matchShortcode = (string as NSString).substring(with: match.range(at: 1))
return emoji.shortcode == matchShortcode
}) else {
continue
}
foundEmojis = true
group.enter()
let request = ImageCache.emojis.get(emoji.url) { (data) in
defer { group.leave() }
guard let data = data, let image = UIImage(data: data) else {
return
}
emojiImages[emoji.shortcode] = image
}
if let request = request {
emojiRequests.append(request)
}
}
guard foundEmojis else {
completion(NSAttributedString(string: string))
return
}
group.notify(queue: .main) { [weak self] in
// if e.g. the account changes before all emojis are loaded, don't bother trying to set them
guard let self = self, self.emojiIdentifier == identifier else { return }
let mutAttrString = NSMutableAttributedString(string: string)
// replaces the emojis starting from the end of the string as to not alter the indices of preceeding emojis
for match in matches.reversed() {
let shortcode = (string as NSString).substring(with: match.range(at: 1))
guard let emojiImage = emojiImages[shortcode] else {
continue
}
let attachment = NSTextAttachment(emojiImage: emojiImage, in: self.emojiFont, with: self.emojiTextColor)
let attachmentStr = NSAttributedString(attachment: attachment)
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
}
completion(mutAttrString)
}
}
}

View File

@ -1,56 +0,0 @@
//
// ComposeStatusReplyView.swift
// Tusker
//
// Created by Shadowfacts on 1/6/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class ComposeStatusReplyView: UIView {
weak var mastodonController: MastodonController?
@IBOutlet weak var avatarImageView: UIImageView!
@IBOutlet weak var displayNameLabel: EmojiLabel!
@IBOutlet weak var usernameLabel: UILabel!
@IBOutlet weak var statusContentTextView: StatusContentTextView!
var avatarRequest: ImageCache.Request?
static func create() -> ComposeStatusReplyView {
return UINib(nibName: "ComposeStatusReplyView", bundle: nil).instantiate(withOwner: nil, options: nil).first as! ComposeStatusReplyView
}
deinit {
avatarRequest?.cancel()
}
override func awakeFromNib() {
super.awakeFromNib()
updateUIForPreferences()
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
}
@objc func updateUIForPreferences() {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
}
func updateUI(for status: StatusMO) {
displayNameLabel.updateForAccountDisplayName(account: status.account)
usernameLabel.text = "@\(status.account.acct)"
statusContentTextView.overrideMastodonController = mastodonController
statusContentTextView.setTextFrom(status: status)
avatarRequest = ImageCache.avatars.get(status.account.avatar) { [weak self] (data) in
guard let self = self, let data = data else { return }
DispatchQueue.main.async {
self.avatarImageView.image = UIImage(data: data)
}
}
}
}

View File

@ -1,83 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16092.1" 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="16082.1"/>
<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"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="iN0-l3-epB" customClass="ComposeStatusReplyView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="Ypn-Ed-MTq">
<rect key="frame" x="8" y="8" width="50" height="50"/>
<constraints>
<constraint firstAttribute="height" constant="50" id="8qi-gl-5ci"/>
<constraint firstAttribute="width" constant="50" id="Dy2-jh-AJj"/>
</constraints>
</imageView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="2cE-sS-Uut">
<rect key="frame" x="66" y="8" width="301" height="651"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Display name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Sdv-dB-Plm" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="107" height="20.5"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" horizontalCompressionResistancePriority="749" text="@username" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="0yZ-71-eTj">
<rect key="frame" x="115" y="0.0" width="178" height="21"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" staticText="YES" notEnabled="YES"/>
<bool key="isElement" value="NO"/>
</accessibility>
<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>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" userInteractionEnabled="NO" contentMode="scaleToFill" scrollEnabled="NO" delaysContentTouches="NO" editable="NO" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="atN-ay-ceL" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="25" width="301" height="626"/>
<string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string>
<color key="textColor" systemColor="labelColor" cocoaTouchSystemColor="darkTextColor"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
</subviews>
<constraints>
<constraint firstAttribute="bottom" secondItem="atN-ay-ceL" secondAttribute="bottom" id="3ub-qq-laF"/>
<constraint firstItem="Sdv-dB-Plm" firstAttribute="leading" secondItem="2cE-sS-Uut" secondAttribute="leading" id="6v5-7p-9gm"/>
<constraint firstItem="Sdv-dB-Plm" firstAttribute="top" secondItem="2cE-sS-Uut" secondAttribute="top" id="YmP-yU-sfe"/>
<constraint firstItem="0yZ-71-eTj" firstAttribute="top" secondItem="2cE-sS-Uut" secondAttribute="top" id="bdX-ge-bMT"/>
<constraint firstAttribute="trailing" secondItem="0yZ-71-eTj" secondAttribute="trailing" constant="8" id="hU7-aZ-ibI"/>
<constraint firstItem="atN-ay-ceL" firstAttribute="leading" secondItem="2cE-sS-Uut" secondAttribute="leading" id="k5c-jg-Dy8"/>
<constraint firstItem="0yZ-71-eTj" firstAttribute="leading" secondItem="Sdv-dB-Plm" secondAttribute="trailing" constant="8" id="m0X-YU-m3V"/>
<constraint firstItem="atN-ay-ceL" firstAttribute="top" secondItem="0yZ-71-eTj" secondAttribute="bottom" constant="4" id="pXc-4g-PAe"/>
<constraint firstAttribute="trailing" secondItem="atN-ay-ceL" secondAttribute="trailing" id="qcg-bA-8ba"/>
</constraints>
</view>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstItem="2cE-sS-Uut" firstAttribute="height" relation="greaterThanOrEqual" secondItem="Ypn-Ed-MTq" secondAttribute="height" id="Fn3-o4-RGx"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="2cE-sS-Uut" secondAttribute="bottom" constant="8" id="G2d-Kz-c4e"/>
<constraint firstItem="Ypn-Ed-MTq" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="8" id="MbW-9d-3gC"/>
<constraint firstItem="2cE-sS-Uut" firstAttribute="leading" secondItem="Ypn-Ed-MTq" secondAttribute="trailing" constant="8" id="TS2-Sr-PB3"/>
<constraint firstItem="2cE-sS-Uut" firstAttribute="top" secondItem="vUN-kp-3ea" secondAttribute="top" constant="8" id="cat-Cr-PSV"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="2cE-sS-Uut" secondAttribute="trailing" constant="8" id="eH4-lG-5UR"/>
<constraint firstItem="Ypn-Ed-MTq" firstAttribute="top" secondItem="vUN-kp-3ea" secondAttribute="top" constant="8" placeholder="YES" id="xCn-8G-jUZ"/>
</constraints>
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
<connections>
<outlet property="avatarImageView" destination="Ypn-Ed-MTq" id="eea-bc-klc"/>
<outlet property="displayNameLabel" destination="Sdv-dB-Plm" id="RxW-Ra-Ups"/>
<outlet property="statusContentTextView" destination="atN-ay-ceL" id="i6A-Rd-rJp"/>
<outlet property="usernameLabel" destination="0yZ-71-eTj" id="VQm-Dq-3zP"/>
</connections>
<point key="canvasLocation" x="138.40000000000001" y="-72.863568215892059"/>
</view>
</objects>
</document>

View File

@ -51,7 +51,7 @@ class ContentTextView: LinkTextView {
func setEmojis(_ emojis: [Emoji]) { func setEmojis(_ emojis: [Emoji]) {
guard !emojis.isEmpty else { return } guard !emojis.isEmpty else { return }
let emojiImages = CachedDictionary<UIImage>(name: "ContentTextView Emoji Images") let emojiImages = MultiThreadDictionary<String, UIImage>(name: "ContentTextView Emoji Images")
let group = DispatchGroup() let group = DispatchGroup()

View File

@ -0,0 +1,59 @@
//
// CustomEmojiImageView.swift
// Tusker
//
// Created by Shadowfacts on 10/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
import Pachyderm
struct CustomEmojiImageView: View {
let emoji: Emoji
@State private var request: ImageCache.Request?
@State private var image: UIImage?
var body: some View {
imageView
.onAppear(perform: self.loadImage)
.onDisappear(perform: self.cancelRequest)
}
@ViewBuilder
private var imageView: some View {
if let image = image {
Image(uiImage: image)
.renderingMode(.original)
.resizable()
.aspectRatio(contentMode: .fit)
} else {
Image(systemName: "smiley.fill")
}
}
private func loadImage() {
request = ImageCache.emojis.get(emoji.url) { (data) in
if let data = data, let image = UIImage(data: data) {
DispatchQueue.main.async {
self.request = nil
self.image = image
}
} else {
DispatchQueue.main.async {
self.request = nil
}
}
}
}
private func cancelRequest() {
request?.cancel()
}
}
//struct CustomEmojiImageView_Previews: PreviewProvider {
// static var previews: some View {
// CustomEmojiImageView()
// }
//}

View File

@ -9,67 +9,23 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) class EmojiLabel: UILabel, BaseEmojiLabel {
class EmojiLabel: UILabel {
private var emojiIdentifier: String? var emojiIdentifier: String?
private var emojiRequests: [ImageCache.Request] = [] var emojiRequests: [ImageCache.Request] = []
var emojiFont: UIFont { font }
var emojiTextColor: UIColor { textColor }
func setEmojis(_ emojis: [Emoji], identifier: String) { func setEmojis(_ emojis: [Emoji], identifier: String) {
guard emojis.count > 0, let attributedText = attributedText else { return } guard emojis.count > 0, let attributedText = attributedText else { return }
self.emojiIdentifier = identifier self.emojiIdentifier = identifier
emojiRequests.forEach { $0.cancel() } emojiRequests.forEach { $0.cancel() }
emojiRequests = [] emojiRequests = []
let matches = emojiRegex.matches(in: attributedText.string, options: [], range: attributedText.fullRange) replaceEmojis(in: attributedText.string, emojis: emojis, identifier: identifier) { [weak self] (newAttributedText) in
guard !matches.isEmpty else { return }
let emojiImages = CachedDictionary<UIImage>(name: "EmojiLabel Emoji Images")
let group = DispatchGroup()
for emoji in emojis {
// only make requests for emojis that are present in the text to avoid making unnecessary network requests
guard matches.contains(where: { (match) in
let matchShortcode = (attributedText.string as NSString).substring(with: match.range(at: 1))
return emoji.shortcode == matchShortcode
}) else {
continue
}
group.enter()
let request = ImageCache.emojis.get(emoji.url) { (data) in
defer { group.leave() }
guard let data = data, let image = UIImage(data: data) else {
return
}
emojiImages[emoji.shortcode] = image
}
if let request = request {
emojiRequests.append(request)
}
}
group.notify(queue: .main) { [weak self] in
// if e.g. the account changes before all emojis are loaded, don't bother trying to set them
guard let self = self, self.emojiIdentifier == identifier else { return } guard let self = self, self.emojiIdentifier == identifier else { return }
self.attributedText = newAttributedText
let mutAttrString = NSMutableAttributedString(attributedString: attributedText)
// replaces the emojis starting from the end of the string as to not alter the indicies of preceeding emojis
for match in matches.reversed() {
let shortcode = (mutAttrString.string as NSString).substring(with: match.range(at: 1))
guard let emojiImage = emojiImages[shortcode] else {
continue
}
let attachment = NSTextAttachment(emojiImage: emojiImage, in: self.font, with: self.textColor)
let attachmentStr = NSAttributedString(attachment: attachment)
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
}
self.attributedText = mutAttrString
self.setNeedsLayout() self.setNeedsLayout()
self.setNeedsDisplay() self.setNeedsDisplay()
} }

View File

@ -0,0 +1,59 @@
//
// MaybeLazyStack.swift
// Tusker
//
// Created by Shadowfacts on 10/10/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
struct MaybeLazyVStack<Content: View>: View {
private let alignment: HorizontalAlignment
private let spacing: CGFloat?
private let content: Content
init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content) {
self.alignment = alignment
self.spacing = spacing
self.content = content()
}
@ViewBuilder
var body: some View {
if #available(iOS 14.0, *) {
LazyVStack(alignment: alignment, spacing: spacing) {
content
}
} else {
VStack(alignment: alignment, spacing: spacing) {
content
}
}
}
}
struct MaybeLazyHStack<Content: View>: View {
private let alignment: VerticalAlignment
private let spacing: CGFloat?
private let content: Content
init(alignment: VerticalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content) {
self.alignment = alignment
self.spacing = spacing
self.content = content()
}
@ViewBuilder
var body: some View {
if #available(iOS 14.0, *) {
LazyHStack(alignment: alignment, spacing: spacing) {
content
}
} else {
HStack(alignment: alignment, spacing: spacing) {
content
}
}
}
}

View File

@ -0,0 +1,50 @@
//
// MultiSourceEmojiLabel.swift
// Tusker
//
// Created by Shadowfacts on 10/18/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
class MultiSourceEmojiLabel: UILabel, BaseEmojiLabel {
var emojiIdentifier: String?
var emojiRequests = [ImageCache.Request]()
var emojiFont: UIFont { font }
var emojiTextColor: UIColor { textColor }
var combiner: (([NSAttributedString]) -> NSAttributedString)?
func setEmojis(pairs: [(String, [Emoji])], identifier: String) {
guard pairs.count > 0 else { return }
self.emojiIdentifier = identifier
emojiRequests.forEach { $0.cancel() }
emojiRequests = []
var attributedStrings = pairs.map { NSAttributedString(string: $0.0) }
func recombine() {
if let combiner = self.combiner {
self.attributedText = combiner(attributedStrings)
}
}
recombine()
for (index, (string, emojis)) in pairs.enumerated() {
self.replaceEmojis(in: string, emojis: emojis, identifier: identifier) { (attributedString) in
attributedStrings[index] = attributedString
DispatchQueue.main.async { [weak self] in
guard let self = self, self.emojiIdentifier == identifier else { return }
recombine()
}
}
}
}
}

View File

@ -19,7 +19,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
@IBOutlet weak var verticalStackView: UIStackView! @IBOutlet weak var verticalStackView: UIStackView!
@IBOutlet weak var actionAvatarStackView: UIStackView! @IBOutlet weak var actionAvatarStackView: UIStackView!
@IBOutlet weak var timestampLabel: UILabel! @IBOutlet weak var timestampLabel: UILabel!
@IBOutlet weak var actionLabel: UILabel! @IBOutlet weak var actionLabel: MultiSourceEmojiLabel!
@IBOutlet weak var statusContentLabel: UILabel! @IBOutlet weak var statusContentLabel: UILabel!
var group: NotificationGroup! var group: NotificationGroup!
@ -35,14 +35,12 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
override func awakeFromNib() { override func awakeFromNib() {
super.awakeFromNib() super.awakeFromNib()
actionLabel.combiner = self.updateActionLabel
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
} }
@objc func updateUIForPreferences() { @objc func updateUIForPreferences() {
// todo: is this compactMap necessary?
let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) }
updateActionLabel(people: people)
for case let imageView as UIImageView in actionAvatarStackView.arrangedSubviews { for case let imageView as UIImageView in actionAvatarStackView.arrangedSubviews {
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: imageView) imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: imageView)
} }
@ -100,8 +98,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
NSLayoutConstraint.activate(imageViews.map { $0.widthAnchor.constraint(equalTo: $0.heightAnchor) }) NSLayoutConstraint.activate(imageViews.map { $0.widthAnchor.constraint(equalTo: $0.heightAnchor) })
updateTimestamp() updateTimestamp()
actionLabel.setEmojis(pairs: people.map { ($0.displayOrUserName, $0.emojis) }, identifier: group.id)
updateActionLabel(people: people)
let doc = try! SwiftSoup.parse(status.content) let doc = try! SwiftSoup.parse(status.content)
statusContentLabel.text = try! doc.text() statusContentLabel.text = try! doc.text()
@ -135,7 +132,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
} }
} }
func updateActionLabel(people: [AccountMO]) { func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString {
let verb: String let verb: String
switch group.kind { switch group.kind {
case .favourite: case .favourite:
@ -145,18 +142,27 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
default: default:
fatalError() fatalError()
} }
let peopleStr: String
// todo: figure out how to localize this // todo: figure out how to localize this
// todo: update to use managed objects let str = NSMutableAttributedString(string: "\(verb) by ")
switch people.count { switch names.count {
case 1: case 1:
peopleStr = people.first!.displayName str.append(names.first!)
case 2: case 2:
peopleStr = people.first!.displayName + " and " + people.last!.displayName str.append(names.first!)
str.append(NSAttributedString(string: " and "))
str.append(names.last!)
default: default:
peopleStr = people.dropLast().map { $0.displayName }.joined(separator: ", ") + ", and " + people.last!.displayName for (index, name) in names.enumerated() {
str.append(name)
if index < names.count - 2 {
str.append(NSAttributedString(string: ", "))
} else if index == names.count - 2 {
str.append(NSAttributedString(string: ", and "))
}
}
} }
actionLabel.text = "\(verb) by \(peopleStr)" return str
} }
override func prepareForReuse() { override func prepareForReuse() {

View File

@ -1,9 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16096" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17506" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/> <device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16086"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17504.1"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies> </dependencies>
<objects> <objects>
@ -29,17 +31,17 @@
</constraints> </constraints>
</stackView> </stackView>
<view contentMode="scaleToFill" horizontalHuggingPriority="249" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="5Ef-5g-b23"> <view contentMode="scaleToFill" horizontalHuggingPriority="249" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="5Ef-5g-b23">
<rect key="frame" x="197.5" y="0.0" width="0.0" height="30"/> <rect key="frame" x="197.5" y="0.0" width="0.5" height="30"/>
</view> </view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" horizontalCompressionResistancePriority="752" text="2m" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="JN0-Bf-3qx"> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" horizontalCompressionResistancePriority="752" text="2m" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="JN0-Bf-3qx">
<rect key="frame" x="205.5" y="0.0" width="24.5" height="30"/> <rect key="frame" x="206" y="0.0" width="24" height="30"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="17"/> <fontDescription key="fontDescription" type="system" weight="light" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/> <color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/> <nil key="highlightedColor"/>
</label> </label>
</subviews> </subviews>
</stackView> </stackView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Actioned by Person 1, Person 2, and Person 3" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="fkn-Gk-ngr"> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Actioned by Person 1, Person 2, and Person 3" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="fkn-Gk-ngr" customClass="MultiSourceEmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="34" width="230" height="41"/> <rect key="frame" x="0.0" y="34" width="230" height="41"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/> <fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/> <nil key="textColor"/>
@ -48,7 +50,7 @@
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Content" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="3" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="lc7-zZ-HrZ"> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Content" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="3" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="lc7-zZ-HrZ">
<rect key="frame" x="0.0" y="79" width="230" height="74"/> <rect key="frame" x="0.0" y="79" width="230" height="74"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/> <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"/> <color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/> <nil key="highlightedColor"/>
</label> </label>
</subviews> </subviews>
@ -83,4 +85,9 @@
<point key="canvasLocation" x="-394.20289855072468" y="56.584821428571423"/> <point key="canvasLocation" x="-394.20289855072468" y="56.584821428571423"/>
</tableViewCell> </tableViewCell>
</objects> </objects>
<resources>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document> </document>

View File

@ -16,7 +16,7 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
@IBOutlet weak var avatarStackView: UIStackView! @IBOutlet weak var avatarStackView: UIStackView!
@IBOutlet weak var timestampLabel: UILabel! @IBOutlet weak var timestampLabel: UILabel!
@IBOutlet weak var actionLabel: UILabel! @IBOutlet weak var actionLabel: MultiSourceEmojiLabel!
var group: NotificationGroup! var group: NotificationGroup!
@ -30,13 +30,12 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
override func awakeFromNib() { override func awakeFromNib() {
super.awakeFromNib() super.awakeFromNib()
actionLabel.combiner = self.updateActionLabel
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
} }
@objc func updateUIForPreferences() { @objc func updateUIForPreferences() {
let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) }
updateActionLabel(people: people)
for case let imageView as UIImageView in avatarStackView.arrangedSubviews { for case let imageView as UIImageView in avatarStackView.arrangedSubviews {
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: imageView) imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: imageView)
} }
@ -47,7 +46,9 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) } let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) }
updateActionLabel(people: people) actionLabel.setEmojis(pairs: people.map {
($0.displayOrUserName, $0.emojis)
}, identifier: group.id)
updateTimestamp() updateTimestamp()
avatarStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } avatarStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
@ -71,20 +72,27 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
} }
} }
func updateActionLabel(people: [AccountMO]) { func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString {
// todo: custom emoji in people display names
// todo: figure out how to localize this // todo: figure out how to localize this
let peopleStr: String let str = NSMutableAttributedString(string: "Followed by ")
switch people.count { switch names.count {
case 1: case 1:
peopleStr = people.first!.displayOrUserName str.append(names.first!)
case 2: case 2:
peopleStr = people.first!.displayOrUserName + " and " + people.last!.displayOrUserName str.append(names.first!)
str.append(NSAttributedString(string: " and "))
str.append(names.last!)
default: default:
peopleStr = people.dropLast().map { $0.displayOrUserName }.joined(separator: ", ") + ", and " + people.last!.displayOrUserName for (index, name) in names.enumerated() {
str.append(name)
if index < names.count - 2 {
str.append(NSAttributedString(string: ", "))
} else if index == names.count - 2 {
str.append(NSAttributedString(string: ", and "))
}
}
} }
actionLabel.text = "Followed by \(peopleStr)" return str
} }
func updateTimestamp() { func updateTimestamp() {

View File

@ -1,9 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14865.1" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17506" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/> <device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14819.2"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17504.1"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies> </dependencies>
<objects> <objects>
@ -29,17 +31,17 @@
</constraints> </constraints>
</stackView> </stackView>
<view contentMode="scaleToFill" horizontalHuggingPriority="249" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="eEp-GR-rtF"> <view contentMode="scaleToFill" horizontalHuggingPriority="249" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="eEp-GR-rtF">
<rect key="frame" x="205.5" y="0.0" width="0.0" height="30"/> <rect key="frame" x="205.5" y="0.0" width="0.5" height="30"/>
</view> </view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" text="2m" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Iub-HC-orP"> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" text="2m" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Iub-HC-orP">
<rect key="frame" x="205.5" y="0.0" width="24.5" height="30"/> <rect key="frame" x="206" y="0.0" width="24" height="30"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="17"/> <fontDescription key="fontDescription" type="system" weight="light" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/> <color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/> <nil key="highlightedColor"/>
</label> </label>
</subviews> </subviews>
</stackView> </stackView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Followed by Person 1 and Person 2" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="bHA-9x-pcO"> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Followed by Person 1 and Person 2" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="bHA-9x-pcO" customClass="MultiSourceEmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="30" width="230" height="46"/> <rect key="frame" x="0.0" y="30" width="230" height="46"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/> <fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/> <nil key="textColor"/>
@ -48,7 +50,7 @@
</subviews> </subviews>
</stackView> </stackView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="person.badge.plus.fill" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="7gy-KD-YT1"> <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="person.badge.plus.fill" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="7gy-KD-YT1">
<rect key="frame" x="36" y="12.5" width="30" height="30.5"/> <rect key="frame" x="34" y="12.5" width="32" height="30"/>
<constraints> <constraints>
<constraint firstAttribute="width" constant="30" id="gvV-4g-2Xr"/> <constraint firstAttribute="width" constant="30" id="gvV-4g-2Xr"/>
<constraint firstAttribute="height" constant="30" id="lS8-fq-ptY"/> <constraint firstAttribute="height" constant="30" id="lS8-fq-ptY"/>
@ -74,6 +76,9 @@
</tableViewCell> </tableViewCell>
</objects> </objects>
<resources> <resources>
<image name="person.badge.plus.fill" catalog="system" width="64" height="58"/> <image name="person.badge.plus.fill" catalog="system" width="128" height="124"/>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources> </resources>
</document> </document>

View File

@ -23,7 +23,11 @@ class ProfileHeaderView: UIView {
return nib.instantiate(withOwner: nil, options: nil).first as! ProfileHeaderView return nib.instantiate(withOwner: nil, options: nil).first as! ProfileHeaderView
} }
weak var delegate: ProfileHeaderViewDelegate? weak var delegate: ProfileHeaderViewDelegate? {
didSet {
createObservers()
}
}
var mastodonController: MastodonController! { delegate?.apiController } var mastodonController: MastodonController! { delegate?.apiController }
@IBOutlet weak var headerImageView: UIImageView! @IBOutlet weak var headerImageView: UIImageView!
@ -41,10 +45,10 @@ class ProfileHeaderView: UIView {
var accountID: String! var accountID: String!
var avatarRequest: ImageCache.Request? private var avatarRequest: ImageCache.Request?
var headerRequest: ImageCache.Request? private var headerRequest: ImageCache.Request?
private var accountUpdater: Cancellable? private var cancellables = [AnyCancellable]()
deinit { deinit {
avatarRequest?.cancel() avatarRequest?.cancel()
@ -67,15 +71,27 @@ class ProfileHeaderView: UIView {
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
if #available(iOS 13.4, *) { moreButton.addInteraction(UIPointerInteraction(delegate: self))
moreButton.addInteraction(UIPointerInteraction(delegate: self))
}
#if SDK_IOS_14
if #available(iOS 14.0, *) { if #available(iOS 14.0, *) {
moreButton.showsMenuAsPrimaryAction = true moreButton.showsMenuAsPrimaryAction = true
moreButton.isContextMenuInteractionEnabled = true moreButton.isContextMenuInteractionEnabled = true
} }
#endif }
private func createObservers() {
cancellables = []
mastodonController.persistentContainer.accountSubject
.filter { [weak self] in $0 == self?.accountID }
.receive(on: DispatchQueue.main)
.sink { [weak self] in self?.updateUI(for: $0) }
.store(in: &cancellables)
mastodonController.persistentContainer.relationshipSubject
.filter { [weak self] in $0 == self?.accountID }
.receive(on: DispatchQueue.main)
.sink { [weak self] (_) in self?.updateRelationship() }
.store(in: &cancellables)
} }
func updateUI(for accountID: String) { func updateUI(for accountID: String) {
@ -115,6 +131,9 @@ class ProfileHeaderView: UIView {
// don't show relationship label for the user's own account // don't show relationship label for the user's own account
if accountID != mastodonController.account?.id { if accountID != mastodonController.account?.id {
// while fetching the most up-to-date, show the current data (if any)
updateRelationship()
let request = Client.getRelationships(accounts: [accountID]) let request = Client.getRelationships(accounts: [accountID])
mastodonController.run(request) { [weak self] (response) in mastodonController.run(request) { [weak self] (response) in
guard let self = self, guard let self = self,
@ -122,9 +141,7 @@ class ProfileHeaderView: UIView {
let relationship = results.first else { let relationship = results.first else {
return return
} }
DispatchQueue.main.async { self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
self.followsYouLabel.isHidden = !relationship.followedBy
}
} }
} }
@ -156,13 +173,14 @@ class ProfileHeaderView: UIView {
nameLabel.heightAnchor.constraint(equalTo: valueTextView.heightAnchor).isActive = true nameLabel.heightAnchor.constraint(equalTo: valueTextView.heightAnchor).isActive = true
} }
}
if accountUpdater == nil {
accountUpdater = mastodonController.persistentContainer.accountSubject private func updateRelationship() {
.filter { [weak self] in $0 == self?.accountID } guard let relationship = mastodonController.persistentContainer.relationship(forAccount: accountID) else {
.receive(on: DispatchQueue.main) return
.sink { [weak self] in self?.updateUI(for: $0) }
} }
followsYouLabel.isHidden = !relationship.followedBy
} }
@objc private func updateUIForPreferences() { @objc private func updateUIForPreferences() {

View File

@ -9,6 +9,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import Combine import Combine
import AVKit
protocol StatusTableViewCellDelegate: TuskerNavigationDelegate { protocol StatusTableViewCellDelegate: TuskerNavigationDelegate {
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell)
@ -70,6 +71,8 @@ class BaseStatusTableViewCell: UITableViewCell {
private var statusUpdater: Cancellable? private var statusUpdater: Cancellable?
private var accountUpdater: Cancellable? private var accountUpdater: Cancellable?
private var currentPictureInPictureVideoStatusID: String?
override func awakeFromNib() { override func awakeFromNib() {
super.awakeFromNib() super.awakeFromNib()
@ -87,11 +90,9 @@ class BaseStatusTableViewCell: UITableViewCell {
accessibilityElements = [displayNameLabel!, contentWarningLabel!, collapseButton!, contentTextView!, attachmentsView!] accessibilityElements = [displayNameLabel!, contentWarningLabel!, collapseButton!, contentTextView!, attachmentsView!]
attachmentsView.isAccessibilityElement = true attachmentsView.isAccessibilityElement = true
#if SDK_IOS_14
if #available(iOS 14.0, *) { if #available(iOS 14.0, *) {
moreButton.showsMenuAsPrimaryAction = true moreButton.showsMenuAsPrimaryAction = true
} }
#endif
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
} }
@ -122,19 +123,25 @@ class BaseStatusTableViewCell: UITableViewCell {
} }
} }
func updateUI(statusID: String, state: StatusState) { final func updateUI(statusID: String, state: StatusState) {
createObserversIfNecessary() createObserversIfNecessary()
guard let status = mastodonController.persistentContainer.status(for: statusID) else { guard let status = mastodonController.persistentContainer.status(for: statusID) else {
fatalError("Missing cached status") fatalError("Missing cached status")
} }
self.statusID = statusID self.statusID = statusID
doUpdateUI(status: status, state: state)
}
func doUpdateUI(status: StatusMO, state: StatusState) {
self.statusState = state self.statusState = state
let account = status.account let account = status.account
self.accountID = account.id self.accountID = account.id
updateUI(account: account) updateUI(account: account)
updateUIForPreferences(account: account) updateUIForPreferences(account: account, status: status)
attachmentsView.updateUI(status: status) attachmentsView.updateUI(status: status)
attachmentsView.isAccessibilityElement = status.attachments.count > 0 attachmentsView.isAccessibilityElement = status.attachments.count > 0
@ -194,12 +201,10 @@ class BaseStatusTableViewCell: UITableViewCell {
reblogButton.accessibilityLabel = NSLocalizedString("Reblog", comment: "reblog button accessibility label") reblogButton.accessibilityLabel = NSLocalizedString("Reblog", comment: "reblog button accessibility label")
} }
#if SDK_IOS_14
if #available(iOS 14.0, *) { if #available(iOS 14.0, *) {
// keep menu in sync with changed states e.g. bookmarked, muted // keep menu in sync with changed states e.g. bookmarked, muted
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: actionsForStatus(statusID: statusID, sourceView: moreButton)) moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: actionsForStatus(statusID: statusID, sourceView: moreButton))
} }
#endif
} }
func updateUI(account: AccountMO) { func updateUI(account: AccountMO) {
@ -213,18 +218,19 @@ class BaseStatusTableViewCell: UITableViewCell {
} }
} }
@objc func preferencesChanged() { @objc private func preferencesChanged() {
guard let mastodonController = mastodonController, guard let mastodonController = mastodonController,
let account = mastodonController.persistentContainer.account(for: accountID), let account = mastodonController.persistentContainer.account(for: accountID),
let status = mastodonController.persistentContainer.status(for: statusID) else { return } let status = mastodonController.persistentContainer.status(for: statusID) else { return }
updateUIForPreferences(account: account) updateUIForPreferences(account: account, status: status)
updateStatusIconsForPreferences(status)
} }
func updateUIForPreferences(account: AccountMO) { func updateUIForPreferences(account: AccountMO, status: StatusMO) {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView) avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
displayNameLabel.updateForAccountDisplayName(account: account) displayNameLabel.updateForAccountDisplayName(account: account)
attachmentsView.contentHidden = Preferences.shared.blurAllMedia || (mastodonController.persistentContainer.status(for: statusID)?.sensitive ?? false) attachmentsView.contentHidden = Preferences.shared.blurAllMedia || (mastodonController.persistentContainer.status(for: statusID)?.sensitive ?? false)
updateStatusIconsForPreferences(status)
} }
func updateStatusIconsForPreferences(_ status: StatusMO) { func updateStatusIconsForPreferences(_ status: StatusMO) {
@ -357,15 +363,74 @@ class BaseStatusTableViewCell: UITableViewCell {
} }
extension BaseStatusTableViewCell: AttachmentViewDelegate { extension BaseStatusTableViewCell: AttachmentViewDelegate {
func attachmentViewGallery(startingAt index: Int) -> UIViewController? { func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? {
guard let delegate = delegate, guard let delegate = delegate,
let status = mastodonController.persistentContainer.status(for: statusID) else { return nil } let status = mastodonController.persistentContainer.status(for: statusID) else { return nil }
let sourceViews = status.attachments.map(attachmentsView.getAttachmentView(for:)) let sourceViews = status.attachments.map(attachmentsView.getAttachmentView(for:))
return delegate.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index) let gallery = delegate.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index)
gallery.avPlayerViewControllerDelegate = self
return gallery
} }
func attachmentViewPresent(_ vc: UIViewController, animated: Bool) { func attachmentViewPresent(_ vc: UIViewController, animated: Bool) {
delegate?.show(vc) delegate?.present(vc, animated: animated)
}
}
// todo: This is not ideal. It works when the original cell remains visible and when the cell is reused, but if the cell is dealloc'd
// resuming from PiP won't work because AVPlayerViewController.delegate is a weak reference.
extension BaseStatusTableViewCell: AVPlayerViewControllerDelegate {
func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) {
// We need to save the current statusID when PiP is initiated, because if the user restores from PiP after this cell has
// been reused, the current value of statusID will not be correct.
currentPictureInPictureVideoStatusID = statusID
}
func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) {
currentPictureInPictureVideoStatusID = nil
}
func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Bool {
// Ideally, when PiP is automatically initiated by app closing the gallery should not be dismissed
// and when PiP is started because the user has tapped the button in the player controls the gallery
// gallery should be dismissed. Unfortunately, this doesn't seem to be possible. Instead, the gallery is
// always dismissed and is recreated when restoring the interface from PiP.
return true
}
func playerViewController(_ playerViewController: AVPlayerViewController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
guard let delegate = delegate,
let playerViewController = playerViewController as? GalleryPlayerViewController,
let id = currentPictureInPictureVideoStatusID,
let status = mastodonController.persistentContainer.status(for: id),
let index = status.attachments.firstIndex(where: { $0.id == playerViewController.attachment?.id }) else {
// returning without invoking completionHandler will dismiss the PiP window
return
}
// We create a new gallery view controller starting at the appropriate index and swap the
// already-playing VC into the appropriate index so it smoothly continues playing.
let sourceViews: [UIImageView?]
if self.statusID == id {
sourceViews = status.attachments.map(attachmentsView.getAttachmentView(for:))
} else {
sourceViews = status.attachments.map { (_) in nil }
}
let gallery = delegate.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index)
gallery.avPlayerViewControllerDelegate = self
// ensure that all other page VCs are created
gallery.loadViewIfNeeded()
// replace the newly created player for the same attachment with the already-playing one
gallery.pages[index] = playerViewController
gallery.setViewControllers([playerViewController], direction: .forward, animated: false, completion: nil)
// this isn't animated, otherwise the animation plays first and then the PiP window expands
// which looks even weirder than the black background appearing instantly and then the PiP window animating
delegate.present(gallery, animated: false) {
completionHandler(false)
}
} }
} }

View File

@ -38,10 +38,9 @@ class ConversationMainStatusTableViewCell: BaseStatusTableViewCell {
contentTextView.defaultFont = .systemFont(ofSize: 18) contentTextView.defaultFont = .systemFont(ofSize: 18)
} }
override func updateUI(statusID: String, state: StatusState) { override func doUpdateUI(status: StatusMO, state: StatusState) {
super.updateUI(statusID: statusID, state: state) super.doUpdateUI(status: status, state: state)
guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError() }
var timestampAndClientText = ConversationMainStatusTableViewCell.dateFormatter.string(from: status.createdAt) var timestampAndClientText = ConversationMainStatusTableViewCell.dateFormatter.string(from: status.createdAt)
if let application = status.applicationName { if let application = status.applicationName {
timestampAndClientText += "\(application)" timestampAndClientText += "\(application)"
@ -63,8 +62,8 @@ class ConversationMainStatusTableViewCell: BaseStatusTableViewCell {
profileAccessibilityElement.accessibilityLabel = account.displayNameWithoutCustomEmoji profileAccessibilityElement.accessibilityLabel = account.displayNameWithoutCustomEmoji
} }
override func updateUIForPreferences(account: AccountMO) { override func updateUIForPreferences(account: AccountMO, status: StatusMO) {
super.updateUIForPreferences(account: account) super.updateUIForPreferences(account: account, status: status)
favoriteAndReblogCountStackView.isHidden = !Preferences.shared.showFavoriteAndReblogCounts favoriteAndReblogCountStackView.isHidden = !Preferences.shared.showFavoriteAndReblogCounts
} }

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17154" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17506" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/> <device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17124"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17504.1"/>
<capability name="Image references" minToolsVersion="12.0"/> <capability name="Image references" minToolsVersion="12.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="System colors in document resources" minToolsVersion="11.0"/>
@ -97,10 +97,10 @@
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/> <textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView> </textView>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="IF9-9U-Gk0" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target"> <view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="IF9-9U-Gk0" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="176" width="343" height="193"/> <rect key="frame" x="0.0" y="176" width="343" height="0.0"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/> <color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
<constraints> <constraints>
<constraint firstAttribute="width" secondItem="IF9-9U-Gk0" secondAttribute="height" multiplier="16:9" id="5oh-eK-J5d"/> <constraint firstAttribute="height" secondItem="IF9-9U-Gk0" secondAttribute="width" multiplier="9:16" priority="999" id="5oh-eK-J5d"/>
</constraints> </constraints>
</view> </view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ejU-sO-Og5"> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ejU-sO-Og5">

View File

@ -68,10 +68,9 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
} }
} }
override func updateUI(statusID: String, state: StatusState) { override func doUpdateUI(status: StatusMO, state: StatusState) {
guard var status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID)") } var status = status
let realStatusID: String
if let rebloggedStatus = status.reblog { if let rebloggedStatus = status.reblog {
reblogStatusID = statusID reblogStatusID = statusID
rebloggerID = status.account.id rebloggerID = status.account.id
@ -79,25 +78,24 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
updateRebloggerLabel(reblogger: status.account) updateRebloggerLabel(reblogger: status.account)
status = rebloggedStatus status = rebloggedStatus
realStatusID = rebloggedStatus.id statusID = rebloggedStatus.id
} else { } else {
reblogStatusID = nil reblogStatusID = nil
rebloggerID = nil rebloggerID = nil
reblogLabel.isHidden = true reblogLabel.isHidden = true
realStatusID = statusID
} }
super.updateUI(statusID: realStatusID, state: state) super.doUpdateUI(status: status, state: state)
updateTimestamp() doUpdateTimestamp(status: status)
let pinned = showPinned && (status.pinned ?? false) let pinned = showPinned && (status.pinned ?? false)
timestampLabel.isHidden = pinned timestampLabel.isHidden = pinned
pinImageView.isHidden = !pinned pinImageView.isHidden = !pinned
} }
@objc override func preferencesChanged() { override func updateUIForPreferences(account: AccountMO, status: StatusMO) {
super.preferencesChanged() super.updateUIForPreferences(account: account, status: status)
if let rebloggerID = rebloggerID, if let rebloggerID = rebloggerID,
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) { let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
@ -121,12 +119,16 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
replyImageView.isHidden = !Preferences.shared.showIsStatusReplyIcon || !showReplyIndicator || status.inReplyToID == nil replyImageView.isHidden = !Preferences.shared.showIsStatusReplyIcon || !showReplyIndicator || status.inReplyToID == nil
} }
func updateTimestamp() { private func updateTimestamp() {
// if the mastodonController is nil (i.e. the delegate is nil), then the screen this cell was a part of has been deallocated // if the mastodonController is nil (i.e. the delegate is nil), then the screen this cell was a part of has been deallocated
// so we bail out immediately, since there's nothing to update // so we bail out immediately, since there's nothing to update
guard let mastodonController = mastodonController else { return } guard let mastodonController = mastodonController else { return }
guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") } guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") }
doUpdateTimestamp(status: status)
}
private func doUpdateTimestamp(status: StatusMO) {
timestampLabel.text = status.createdAt.timeAgoString() timestampLabel.text = status.createdAt.timeAgoString()
timestampLabel.accessibilityLabel = TimelineStatusTableViewCell.relativeDateFormatter.localizedString(for: status.createdAt, relativeTo: Date()) timestampLabel.accessibilityLabel = TimelineStatusTableViewCell.relativeDateFormatter.localizedString(for: status.createdAt, relativeTo: Date())

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17154" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17506" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/> <device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17124"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17504.1"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -115,55 +115,14 @@
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/> <textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView> </textView>
<view hidden="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="nbq-yr-2mA" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target"> <view hidden="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="nbq-yr-2mA" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="169.5" width="277" height="156"/> <rect key="frame" x="0.0" y="169.5" width="277" height="0.0"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/> <color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
<constraints> <constraints>
<constraint firstAttribute="width" secondItem="nbq-yr-2mA" secondAttribute="height" multiplier="16:9" id="Rvt-zs-fkd"/> <constraint firstAttribute="height" secondItem="nbq-yr-2mA" secondAttribute="width" multiplier="9:16" priority="999" id="Rvt-zs-fkd"/>
</constraints> </constraints>
</view> </view>
</subviews> </subviews>
</stackView> </stackView>
<stackView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" distribution="fillEqually" translatesAutoresizingMaskIntoConstraints="NO" id="Zlb-yt-NTw">
<rect key="frame" x="0.0" y="169.5" width="343" height="26"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="rKF-yF-KIa">
<rect key="frame" x="0.0" y="0.0" width="86" height="26"/>
<accessibility key="accessibilityConfiguration" label="Reply"/>
<fontDescription key="fontDescription" type="system" pointSize="18"/>
<state key="normal" image="arrowshape.turn.up.left.fill" catalog="system"/>
<connections>
<action selector="replyPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="ybz-3W-jAa"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="x0t-TR-jJ4">
<rect key="frame" x="86" y="0.0" width="85.5" height="26"/>
<accessibility key="accessibilityConfiguration" label="Favorite"/>
<state key="normal" image="star.fill" catalog="system"/>
<connections>
<action selector="favoritePressed" destination="iN0-l3-epB" eventType="touchUpInside" id="8Q8-Rz-k02"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="6tW-z8-Qh9">
<rect key="frame" x="171.5" y="0.0" width="86" height="26"/>
<accessibility key="accessibilityConfiguration" label="Reblog"/>
<state key="normal" image="repeat" catalog="system"/>
<connections>
<action selector="reblogPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="Wa2-ZA-TBo"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="982-J4-NGl">
<rect key="frame" x="257.5" y="0.0" width="85.5" height="26"/>
<accessibility key="accessibilityConfiguration" label="More Actions"/>
<state key="normal" image="ellipsis" catalog="system"/>
<connections>
<action selector="morePressed" destination="iN0-l3-epB" eventType="touchUpInside" id="WT4-fi-usq"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstAttribute="height" constant="26" id="PnB-XK-9U0"/>
</constraints>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" distribution="equalSpacing" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="oie-wK-IpU"> <stackView opaque="NO" contentMode="scaleToFill" distribution="equalSpacing" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="oie-wK-IpU">
<rect key="frame" x="0.0" y="54" width="50" height="22"/> <rect key="frame" x="0.0" y="54" width="50" height="22"/>
<subviews> <subviews>
@ -186,20 +145,78 @@
</imageView> </imageView>
</subviews> </subviews>
</stackView> </stackView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="TUP-Nz-5Yh">
<rect key="frame" x="0.0" y="169.5" width="335" height="26"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="rKF-yF-KIa">
<rect key="frame" x="0.0" y="0.0" width="84" height="26"/>
<accessibility key="accessibilityConfiguration" label="Reply"/>
<fontDescription key="fontDescription" type="system" pointSize="18"/>
<state key="normal" image="arrowshape.turn.up.left.fill" catalog="system"/>
<connections>
<action selector="replyPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="ybz-3W-jAa"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="6tW-z8-Qh9">
<rect key="frame" x="167.5" y="0.0" width="84" height="26"/>
<accessibility key="accessibilityConfiguration" label="Reblog"/>
<state key="normal" image="repeat" catalog="system"/>
<connections>
<action selector="reblogPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="Wa2-ZA-TBo"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="982-J4-NGl">
<rect key="frame" x="251.5" y="0.0" width="83.5" height="26"/>
<accessibility key="accessibilityConfiguration" label="More Actions"/>
<state key="normal" image="ellipsis" catalog="system"/>
<connections>
<action selector="morePressed" destination="iN0-l3-epB" eventType="touchUpInside" id="WT4-fi-usq"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="x0t-TR-jJ4">
<rect key="frame" x="84" y="0.0" width="83.5" height="26"/>
<accessibility key="accessibilityConfiguration" label="Favorite"/>
<state key="normal" image="star.fill" catalog="system"/>
<connections>
<action selector="favoritePressed" destination="iN0-l3-epB" eventType="touchUpInside" id="8Q8-Rz-k02"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstAttribute="height" constant="26" id="1FK-Er-G11"/>
<constraint firstAttribute="bottom" secondItem="rKF-yF-KIa" secondAttribute="bottom" id="KyG-2C-MgN"/>
<constraint firstItem="x0t-TR-jJ4" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="L3w-JH-eeG"/>
<constraint firstItem="6tW-z8-Qh9" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="N7j-f4-gvP"/>
<constraint firstItem="982-J4-NGl" firstAttribute="leading" secondItem="6tW-z8-Qh9" secondAttribute="trailing" id="VQo-DJ-C7L"/>
<constraint firstItem="982-J4-NGl" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="W53-1a-fKu"/>
<constraint firstItem="x0t-TR-jJ4" firstAttribute="leading" secondItem="rKF-yF-KIa" secondAttribute="trailing" id="WPd-A2-6Ju"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="width" secondItem="x0t-TR-jJ4" secondAttribute="width" id="X7m-pJ-oje"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="leading" secondItem="TUP-Nz-5Yh" secondAttribute="leading" placeholder="YES" id="aFR-Ew-99S"/>
<constraint firstAttribute="bottom" secondItem="982-J4-NGl" secondAttribute="bottom" id="eXy-3h-51w"/>
<constraint firstAttribute="bottom" secondItem="x0t-TR-jJ4" secondAttribute="bottom" id="euN-Nf-rwh"/>
<constraint firstItem="6tW-z8-Qh9" firstAttribute="leading" secondItem="x0t-TR-jJ4" secondAttribute="trailing" id="oAK-VG-bbp"/>
<constraint firstAttribute="bottom" secondItem="6tW-z8-Qh9" secondAttribute="bottom" id="tpf-Q3-V3l"/>
<constraint firstAttribute="trailing" secondItem="982-J4-NGl" secondAttribute="trailing" id="uQG-FZ-F7u"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="width" secondItem="982-J4-NGl" secondAttribute="width" id="vir-iq-biv"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="width" secondItem="6tW-z8-Qh9" secondAttribute="width" id="vqw-d7-VtZ"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="wWH-J7-egM"/>
</constraints>
</view>
</subviews> </subviews>
<constraints> <constraints>
<constraint firstItem="gIY-Wp-RSk" firstAttribute="leading" secondItem="QMP-j2-HLn" secondAttribute="trailing" constant="8" id="0Tm-v7-Ts4"/> <constraint firstItem="gIY-Wp-RSk" firstAttribute="leading" secondItem="QMP-j2-HLn" secondAttribute="trailing" constant="8" id="0Tm-v7-Ts4"/>
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="QMP-j2-HLn" secondAttribute="bottom" constant="8" id="2Ao-Gj-fY3"/> <constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="QMP-j2-HLn" secondAttribute="bottom" constant="8" id="2Ao-Gj-fY3"/>
<constraint firstItem="TUP-Nz-5Yh" firstAttribute="trailing" secondItem="ve3-Y1-NQH" secondAttribute="trailingMargin" id="3l0-tE-Ak1"/>
<constraint firstItem="TUP-Nz-5Yh" firstAttribute="top" secondItem="gIY-Wp-RSk" secondAttribute="bottom" id="4KL-a3-qyf"/>
<constraint firstItem="oie-wK-IpU" firstAttribute="top" secondItem="QMP-j2-HLn" secondAttribute="bottom" constant="4" id="7Mp-WS-FhY"/> <constraint firstItem="oie-wK-IpU" firstAttribute="top" secondItem="QMP-j2-HLn" secondAttribute="bottom" constant="4" id="7Mp-WS-FhY"/>
<constraint firstAttribute="bottom" secondItem="Zlb-yt-NTw" secondAttribute="bottom" id="HOe-6l-ES0"/> <constraint firstItem="TUP-Nz-5Yh" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="oie-wK-IpU" secondAttribute="bottom" id="7Xp-Sa-Rfk"/>
<constraint firstItem="QMP-j2-HLn" firstAttribute="top" secondItem="ve3-Y1-NQH" secondAttribute="top" id="PC4-Bi-QXm"/> <constraint firstItem="QMP-j2-HLn" firstAttribute="top" secondItem="ve3-Y1-NQH" secondAttribute="top" id="PC4-Bi-QXm"/>
<constraint firstItem="oie-wK-IpU" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="ve3-Y1-NQH" secondAttribute="leading" id="QKi-ny-jOJ"/> <constraint firstItem="oie-wK-IpU" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="ve3-Y1-NQH" secondAttribute="leading" id="QKi-ny-jOJ"/>
<constraint firstItem="Zlb-yt-NTw" firstAttribute="leading" secondItem="ve3-Y1-NQH" secondAttribute="leading" id="aUm-Uo-wkY"/> <constraint firstItem="TUP-Nz-5Yh" firstAttribute="leading" secondItem="ve3-Y1-NQH" secondAttribute="leading" id="QZ2-iO-ckC"/>
<constraint firstItem="Zlb-yt-NTw" firstAttribute="top" secondItem="gIY-Wp-RSk" secondAttribute="bottom" id="dOI-nF-1L9"/>
<constraint firstItem="gIY-Wp-RSk" firstAttribute="top" secondItem="QMP-j2-HLn" secondAttribute="top" id="fEd-wN-kuQ"/> <constraint firstItem="gIY-Wp-RSk" firstAttribute="top" secondItem="QMP-j2-HLn" secondAttribute="top" id="fEd-wN-kuQ"/>
<constraint firstItem="gIY-Wp-RSk" firstAttribute="leading" secondItem="oie-wK-IpU" secondAttribute="trailing" constant="8" id="fqd-p6-oGe"/> <constraint firstItem="gIY-Wp-RSk" firstAttribute="leading" secondItem="oie-wK-IpU" secondAttribute="trailing" constant="8" id="fqd-p6-oGe"/>
<constraint firstAttribute="trailing" secondItem="Zlb-yt-NTw" secondAttribute="trailing" id="gxD-EE-ISA"/>
<constraint firstAttribute="trailingMargin" secondItem="gIY-Wp-RSk" secondAttribute="trailing" id="hKk-kO-wFT"/> <constraint firstAttribute="trailingMargin" secondItem="gIY-Wp-RSk" secondAttribute="trailing" id="hKk-kO-wFT"/>
<constraint firstAttribute="bottom" secondItem="TUP-Nz-5Yh" secondAttribute="bottom" id="rmQ-QM-Llu"/>
<constraint firstItem="QMP-j2-HLn" firstAttribute="leading" secondItem="ve3-Y1-NQH" secondAttribute="leading" id="zeW-tQ-uJl"/> <constraint firstItem="QMP-j2-HLn" firstAttribute="leading" secondItem="ve3-Y1-NQH" secondAttribute="leading" id="zeW-tQ-uJl"/>
</constraints> </constraints>
</view> </view>

View File

@ -52,9 +52,7 @@ class VisualEffectImageButton: UIControl {
imageView.bottomAnchor.constraint(equalTo: vibrancyView.bottomAnchor, constant: -2), imageView.bottomAnchor.constraint(equalTo: vibrancyView.bottomAnchor, constant: -2),
]) ])
#if SDK_IOS_14
addInteraction(UIContextMenuInteraction(delegate: self)) addInteraction(UIContextMenuInteraction(delegate: self))
#endif
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(onTap))) addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(onTap)))
} }
@ -63,12 +61,10 @@ class VisualEffectImageButton: UIControl {
sendActions(for: .touchUpInside) sendActions(for: .touchUpInside)
} }
#if SDK_IOS_14
override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
guard let menu = menu else { return nil } guard let menu = menu else { return nil }
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) -> UIMenu? in return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) -> UIMenu? in
return menu return menu
} }
} }
#endif
} }

View File

@ -16,12 +16,16 @@ fileprivate class WeakWrapper<T: AnyObject> {
} }
} }
struct WeakArray<Element: AnyObject>: Collection { struct WeakArray<Element: AnyObject>: MutableCollection, RangeReplaceableCollection {
private var array: [WeakWrapper<Element>] private var array: [WeakWrapper<Element>]
var startIndex: Int { array.startIndex } var startIndex: Int { array.startIndex }
var endIndex: Int { array.endIndex } var endIndex: Int { array.endIndex }
init() {
array = []
}
init(_ elements: [Element]) { init(_ elements: [Element]) {
array = elements.map { WeakWrapper($0) } array = elements.map { WeakWrapper($0) }
} }
@ -30,11 +34,20 @@ struct WeakArray<Element: AnyObject>: Collection {
array = elements.map { WeakWrapper($0) } array = elements.map { WeakWrapper($0) }
} }
subscript(_ index: Int) -> Element? { subscript(position: Int) -> Element? {
return array[index].value get {
array[position].value
}
set(newValue) {
array[position] = WeakWrapper(newValue)
}
} }
func index(after i: Int) -> Int { func index(after i: Int) -> Int {
return array.index(after: i) return array.index(after: i)
} }
mutating func replaceSubrange<C>(_ subrange: Range<Int>, with newElements: C) where C : Collection, Self.Element == C.Element {
array.replaceSubrange(subrange, with: newElements.map { WeakWrapper($0) })
}
} }

View File

@ -0,0 +1,25 @@
//
// FuzzyMatcherTests.swift
// TuskerTests
//
// Created by Shadowfacts on 10/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import XCTest
@testable import Tusker
class FuzzyMatcherTests: XCTestCase {
func testExample() throws {
XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "foo").score, 6)
XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "faoao").score, 4)
XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "aaa").score, -6)
XCTAssertEqual(FuzzyMatcher.match(pattern: "bar", str: "baz").score, 2)
XCTAssertEqual(FuzzyMatcher.match(pattern: "bar", str: "bur").score, 1)
XCTAssertGreaterThan(FuzzyMatcher.match(pattern: "sir", str: "sir").score, FuzzyMatcher.match(pattern: "sir", str: "georgespolitzer").score)
}
}