Compare commits
38 Commits
14e8c11f02
...
1c871a12a1
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 1c871a12a1 | |
Shadowfacts | 8a528936b8 | |
Shadowfacts | 744329dca2 | |
Shadowfacts | 45ac40b125 | |
Shadowfacts | 2426989161 | |
Shadowfacts | 1439c8b162 | |
Shadowfacts | 5125cc3397 | |
Shadowfacts | 9b949af390 | |
Shadowfacts | 3ff9fdabdb | |
Shadowfacts | a805da9faa | |
Shadowfacts | e0acb0f04a | |
Shadowfacts | 5414f2329c | |
Shadowfacts | 08045dd1e9 | |
Shadowfacts | 288f855e2f | |
Shadowfacts | 7883b04618 | |
Shadowfacts | 0687c040a0 | |
Shadowfacts | 58c6d508ec | |
Shadowfacts | ae272582ac | |
Shadowfacts | 1a4517c43a | |
Shadowfacts | 2cfc0cf28a | |
Shadowfacts | cf63384dce | |
Shadowfacts | 733d50b642 | |
Shadowfacts | 0e60e74a8a | |
Shadowfacts | fd0054addf | |
Shadowfacts | 576e4aa90d | |
Shadowfacts | ea3de4cdda | |
Shadowfacts | 83c7609df5 | |
Shadowfacts | 809584cc54 | |
Shadowfacts | 9b85090884 | |
Shadowfacts | 6965a4c374 | |
Shadowfacts | b6c0c02028 | |
Shadowfacts | 42f9d19ee9 | |
Shadowfacts | b80a61cc95 | |
Shadowfacts | 0d972d987c | |
Shadowfacts | 3e33c8e6f9 | |
Shadowfacts | 3822d536c8 | |
Shadowfacts | 5906c374ba | |
Shadowfacts | ee90b20f7f |
28
CHANGELOG.md
28
CHANGELOG.md
|
@ -1,5 +1,33 @@
|
|||
# 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)
|
||||
This build is a hotfix for a couple pressing issues. The changelog for the previous build is included below.
|
||||
|
||||
|
|
|
@ -52,10 +52,11 @@ public class Client {
|
|||
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 {
|
||||
completion(.failure(Error.invalidRequest))
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
let task = session.dataTask(with: request) { data, response, error in
|
||||
|
@ -83,6 +84,7 @@ public class Client {
|
|||
completion(.success(result, pagination))
|
||||
}
|
||||
task.resume()
|
||||
return task
|
||||
}
|
||||
|
||||
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
|
||||
|
@ -276,12 +278,12 @@ public class Client {
|
|||
}
|
||||
|
||||
// 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: [
|
||||
"q" => query,
|
||||
"resolve" => resolve,
|
||||
"limit" => limit
|
||||
])
|
||||
"limit" => limit,
|
||||
] + "types" => types?.map { $0.rawValue })
|
||||
}
|
||||
|
||||
// MARK: - Statuses
|
||||
|
@ -314,13 +316,24 @@ public class Client {
|
|||
}
|
||||
|
||||
|
||||
// MARK: Bookmarks
|
||||
// MARK: - Bookmarks
|
||||
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
|
||||
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
|
||||
request.range = range
|
||||
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 {
|
||||
|
|
|
@ -18,6 +18,7 @@ public class Relationship: Decodable {
|
|||
public let followRequested: Bool
|
||||
public let domainBlocking: Bool
|
||||
public let showingReblogs: Bool
|
||||
public let endorsed: Bool?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
|
@ -29,5 +30,6 @@ public class Relationship: Decodable {
|
|||
case followRequested = "requested"
|
||||
case domainBlocking = "domain_blocking"
|
||||
case showingReblogs = "showing_reblogs"
|
||||
case endorsed
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -140,7 +140,7 @@
|
|||
D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC19123C271D9000D0238 /* MastodonActivity.swift */; };
|
||||
D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AAC2128D88B005A6F37 /* LocalData.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 */; };
|
||||
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvAttachmentView.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 */; };
|
||||
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 */; };
|
||||
D670F8B62537DC890046588A /* EmojiPickerWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D670F8B52537DC890046588A /* EmojiPickerWrapper.swift */; };
|
||||
D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7B2157E01900721E32 /* XCBManager.swift */; };
|
||||
D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */; };
|
||||
D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A812157E8FA00721E32 /* XCBSession.swift */; };
|
||||
|
@ -173,8 +174,6 @@
|
|||
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67B506C250B291200FAECFB /* BlurHashDecode.swift */; };
|
||||
D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */; };
|
||||
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */; };
|
||||
D67C57B221E28FAD00C3118B /* ComposeStatusReplyView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D67C57B121E28FAD00C3118B /* ComposeStatusReplyView.xib */; };
|
||||
D67C57B421E2910700C3118B /* ComposeStatusReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57B321E2910700C3118B /* ComposeStatusReplyView.swift */; };
|
||||
D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */; };
|
||||
D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68015412401A74600D6103B /* MediaPrefsView.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 */; };
|
||||
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.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 */; };
|
||||
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D690797224A4EF9700023A34 /* UIBezierPath+Helpers.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 */; };
|
||||
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.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 */; };
|
||||
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.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 */; };
|
||||
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.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 */; };
|
||||
D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* WeakArray.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 */; };
|
||||
D6E6F26521604242006A8599 /* CharacterCounterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26421604242006A8599 /* CharacterCounterTests.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -489,6 +500,7 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -502,8 +514,6 @@
|
|||
D67B506C250B291200FAECFB /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = "<group>"; };
|
||||
D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeAccountDetailView.swift; sourceTree = "<group>"; };
|
||||
D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Uniques.swift"; sourceTree = "<group>"; };
|
||||
D67C57B121E28FAD00C3118B /* ComposeStatusReplyView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ComposeStatusReplyView.xib; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -515,6 +525,8 @@
|
|||
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedProgressView.swift; sourceTree = "<group>"; };
|
||||
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = "<group>"; };
|
||||
D68E525C24A3E8F00054355A /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -564,6 +576,9 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -592,9 +607,16 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -757,6 +779,7 @@
|
|||
D61099F82145698900432DC2 /* Relationship.swift */,
|
||||
D61099FA214569F600432DC2 /* Report.swift */,
|
||||
D61099FC21456A1D00432DC2 /* SearchResults.swift */,
|
||||
D6E426B8253382B300C02E1C /* SearchResultType.swift */,
|
||||
D61099FE21456A4C00432DC2 /* Status.swift */,
|
||||
D6285B4E21EA695800FE4B39 /* StatusContentType.swift */,
|
||||
D6109A10214607D500432DC2 /* Timeline.swift */,
|
||||
|
@ -884,6 +907,7 @@
|
|||
D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */,
|
||||
D60E2F232442372B005F8713 /* StatusMO.swift */,
|
||||
D60E2F252442372B005F8713 /* AccountMO.swift */,
|
||||
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */,
|
||||
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
|
||||
);
|
||||
path = CoreData;
|
||||
|
@ -991,6 +1015,11 @@
|
|||
D622757724EE133700B82A16 /* ComposeAssetPicker.swift */,
|
||||
D62275A524F1C81800B82A16 /* ComposeReplyView.swift */,
|
||||
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */,
|
||||
D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */,
|
||||
D6C143D9253510F4007DC240 /* ComposeContentWarningTextField.swift */,
|
||||
D6C143DF25354E34007DC240 /* EmojiPickerCollectionViewController.swift */,
|
||||
D6C143FB25354FD0007DC240 /* EmojiCollectionViewCell.swift */,
|
||||
D670F8B52537DC890046588A /* EmojiPickerWrapper.swift */,
|
||||
);
|
||||
path = Compose;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1145,15 +1174,6 @@
|
|||
path = "Account Detail";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D67C57B021E28F9400C3118B /* Compose Status Reply */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D67C57B121E28FAD00C3118B /* ComposeStatusReplyView.xib */,
|
||||
D67C57B321E2910700C3118B /* ComposeStatusReplyView.swift */,
|
||||
);
|
||||
path = "Compose Status Reply";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D6A3BC7223218C6E00FD64D5 /* Utilities */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -1262,7 +1282,9 @@
|
|||
D620483323D3801D008A63EF /* LinkTextView.swift */,
|
||||
D620483523D38075008A63EF /* ContentTextView.swift */,
|
||||
D620483723D38190008A63EF /* StatusContentTextView.swift */,
|
||||
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */,
|
||||
D6969E9F240C8384002843CE /* EmojiLabel.swift */,
|
||||
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
|
||||
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */,
|
||||
04ED00B021481ED800567C53 /* SteppedProgressView.swift */,
|
||||
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
|
||||
|
@ -1272,8 +1294,9 @@
|
|||
D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */,
|
||||
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */,
|
||||
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
|
||||
D6E426802532814100C02E1C /* MaybeLazyStack.swift */,
|
||||
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
|
||||
D67C57A721E2649B00C3118B /* Account Detail */,
|
||||
D67C57B021E28F9400C3118B /* Compose Status Reply */,
|
||||
D626494023C122C800612E6E /* Asset Picker */,
|
||||
D61959D0241E842400A37B8E /* Draft Cell */,
|
||||
D641C78A213DD926004B4513 /* Status */,
|
||||
|
@ -1352,9 +1375,10 @@
|
|||
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
|
||||
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */,
|
||||
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */,
|
||||
D64D8CA82463B494006B0BAA /* CachedDictionary.swift */,
|
||||
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
|
||||
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
|
||||
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
|
||||
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
|
||||
D67B506B250B28FF00FAECFB /* Vendor */,
|
||||
D6F1F84E2193B9BE00F5FE67 /* Caching */,
|
||||
D6757A7A2157E00100721E32 /* XCallbackURL */,
|
||||
|
@ -1380,6 +1404,7 @@
|
|||
children = (
|
||||
D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */,
|
||||
D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */,
|
||||
D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */,
|
||||
D6D4DDE6212518A200E1C4BB /* Info.plist */,
|
||||
);
|
||||
path = TuskerTests;
|
||||
|
@ -1645,7 +1670,6 @@
|
|||
D626493D23C1000300612E6E /* AlbumTableViewCell.xib in Resources */,
|
||||
D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */,
|
||||
D64BC19023C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib in Resources */,
|
||||
D67C57B221E28FAD00C3118B /* ComposeStatusReplyView.xib in Resources */,
|
||||
D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */,
|
||||
D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */,
|
||||
D6F2E966249E8BFD005846BB /* CrashReporterViewController.xib in Resources */,
|
||||
|
@ -1711,6 +1735,7 @@
|
|||
D61099CB2144B20500432DC2 /* Request.swift in Sources */,
|
||||
D6109A05214572BF00432DC2 /* Scope.swift in Sources */,
|
||||
D6109A11214607D500432DC2 /* Timeline.swift in Sources */,
|
||||
D6E426B9253382B300C02E1C /* SearchResultType.swift in Sources */,
|
||||
D60E2F3324425374005F8713 /* AccountProtocol.swift in Sources */,
|
||||
D61099E7214561FF00432DC2 /* Attachment.swift in Sources */,
|
||||
D60E2F3124424F1A005F8713 /* StatusProtocol.swift in Sources */,
|
||||
|
@ -1805,11 +1830,13 @@
|
|||
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
|
||||
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
|
||||
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */,
|
||||
D6C143FD25354FD0007DC240 /* EmojiCollectionViewCell.swift in Sources */,
|
||||
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
|
||||
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */,
|
||||
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */,
|
||||
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */,
|
||||
D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */,
|
||||
D6E426812532814100C02E1C /* MaybeLazyStack.swift in Sources */,
|
||||
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */,
|
||||
D6A3BC852321F6C100FD64D5 /* AccountListTableViewController.swift in Sources */,
|
||||
D64BC18823C1640A000D0238 /* PinStatusActivity.swift in Sources */,
|
||||
|
@ -1818,7 +1845,7 @@
|
|||
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */,
|
||||
D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */,
|
||||
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
|
||||
D67C57B421E2910700C3118B /* ComposeStatusReplyView.swift in Sources */,
|
||||
D6C143E025354E34007DC240 /* EmojiPickerCollectionViewController.swift in Sources */,
|
||||
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
|
||||
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
|
||||
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,
|
||||
|
@ -1833,6 +1860,7 @@
|
|||
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */,
|
||||
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
|
||||
D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */,
|
||||
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */,
|
||||
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */,
|
||||
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */,
|
||||
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */,
|
||||
|
@ -1844,6 +1872,7 @@
|
|||
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */,
|
||||
D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */,
|
||||
D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */,
|
||||
D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */,
|
||||
D63F9C6E241D2D85004C03CF /* CompositionAttachment.swift in Sources */,
|
||||
D6AEBB4523216AF800E5038B /* FollowAccountActivity.swift in Sources */,
|
||||
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */,
|
||||
|
@ -1852,6 +1881,7 @@
|
|||
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */,
|
||||
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
|
||||
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */,
|
||||
D6C143DA253510F4007DC240 /* ComposeContentWarningTextField.swift in Sources */,
|
||||
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */,
|
||||
D627943923A553B600D38C68 /* UnbookmarkStatusActivity.swift in Sources */,
|
||||
D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */,
|
||||
|
@ -1865,6 +1895,7 @@
|
|||
D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */,
|
||||
D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */,
|
||||
D6434EB3215B1856001A919A /* XCBRequest.swift in Sources */,
|
||||
D6E4269D2532A3E100C02E1C /* FuzzyMatcher.swift in Sources */,
|
||||
D681E4D3246E2AFF0053414F /* MuteConversationActivity.swift in Sources */,
|
||||
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */,
|
||||
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */,
|
||||
|
@ -1893,6 +1924,7 @@
|
|||
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */,
|
||||
D64F80E2215875CC00BEF393 /* XCBActionType.swift in Sources */,
|
||||
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */,
|
||||
D670F8B62537DC890046588A /* EmojiPickerWrapper.swift in Sources */,
|
||||
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */,
|
||||
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
|
||||
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */,
|
||||
|
@ -1915,8 +1947,9 @@
|
|||
D641C773213CAA25004B4513 /* NotificationsTableViewController.swift in Sources */,
|
||||
D6412B0524B0227D00F5412E /* ProfileViewController.swift in Sources */,
|
||||
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */,
|
||||
D64D8CA92463B494006B0BAA /* CachedDictionary.swift in Sources */,
|
||||
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
|
||||
D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */,
|
||||
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
|
||||
D6F1F84D2193B56E00F5FE67 /* Cache.swift in Sources */,
|
||||
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,
|
||||
0427037C22B316B9000D31B6 /* SilentActionPrefs.swift in Sources */,
|
||||
|
@ -1926,11 +1959,13 @@
|
|||
D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */,
|
||||
D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */,
|
||||
D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */,
|
||||
D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */,
|
||||
D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */,
|
||||
D677284E24ECC01D00C732D3 /* Draft.swift in Sources */,
|
||||
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
|
||||
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,
|
||||
D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */,
|
||||
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
|
||||
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
|
||||
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
|
||||
D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */,
|
||||
|
@ -1944,6 +1979,7 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */,
|
||||
D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */,
|
||||
D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -2229,7 +2265,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
CURRENT_PROJECT_VERSION = 11;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
|
@ -2258,7 +2294,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
CURRENT_PROJECT_VERSION = 11;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
|
|
|
@ -89,6 +89,13 @@
|
|||
isEnabled = "YES">
|
||||
</CommandLineArgument>
|
||||
</CommandLineArguments>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "DISABLE_IMAGE_CACHE"
|
||||
value = "1"
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
|
|
@ -29,10 +29,13 @@ class FollowAccountActivity: AccountActivity {
|
|||
|
||||
let request = Account.follow(account.id)
|
||||
mastodonController.run(request) { (response) in
|
||||
if case .failure(_) = response {
|
||||
switch response {
|
||||
case .failure(_):
|
||||
// todo: display error message
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||
fatalError()
|
||||
case let .success(relationship, _):
|
||||
self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,10 +29,13 @@ class UnfollowAccountActivity: AccountActivity {
|
|||
|
||||
let request = Account.unfollow(account.id)
|
||||
mastodonController.run(request) { (response) in
|
||||
if case .failure(_) = response {
|
||||
switch response {
|
||||
case .failure(_):
|
||||
// todo: display error message
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||
fatalError()
|
||||
case let .success(relationship, _):
|
||||
self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -15,6 +15,7 @@ enum Cache<T> {
|
|||
case disk(DiskStorage<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 {
|
||||
switch self {
|
||||
case let .memory(memory):
|
||||
|
|
|
@ -16,9 +16,17 @@ class ImageCache {
|
|||
static let attachments = ImageCache(name: "Attachments", memoryExpiry: .seconds(60 * 2))
|
||||
static let emojis = ImageCache(name: "Emojis", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60))
|
||||
|
||||
#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 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) {
|
||||
let storage = MemoryStorage<Data>(config: MemoryConfig(expiry: expiry))
|
||||
|
@ -38,9 +46,15 @@ class ImageCache {
|
|||
|
||||
func get(_ url: URL, completion: ((Data?) -> Void)?) -> Request? {
|
||||
let key = url.absoluteString
|
||||
if (try? cache.existsObject(forKey: key)) ?? false,
|
||||
let data = try? cache.object(forKey: key) {
|
||||
completion?(data)
|
||||
if !ImageCache.disableCaching,
|
||||
// todo: calling object(forKey: key) does disk I/O and this method is often called from the main thread
|
||||
// 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
|
||||
} else {
|
||||
if let completion = completion, let group = groups[url] {
|
||||
|
@ -50,7 +64,7 @@ class ImageCache {
|
|||
if let data = data {
|
||||
try? self.cache.setObject(data, forKey: key)
|
||||
}
|
||||
self.groups.removeValue(forKey: url)
|
||||
self.groups.removeValueWithoutReturning(forKey: url)
|
||||
}
|
||||
groups[url] = group
|
||||
let request = group.addCallback(completion)
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import Foundation
|
||||
import Pachyderm
|
||||
|
||||
class MastodonController {
|
||||
class MastodonController: ObservableObject {
|
||||
|
||||
static private(set) var all = [LocalData.UserAccountInfo: MastodonController]()
|
||||
|
||||
|
@ -42,8 +42,9 @@ class MastodonController {
|
|||
|
||||
let client: Client!
|
||||
|
||||
var account: Account!
|
||||
var instance: Instance!
|
||||
@Published private(set) var account: Account!
|
||||
@Published private(set) var instance: Instance!
|
||||
private(set) var customEmojis: [Emoji]?
|
||||
|
||||
var loggedIn: Bool {
|
||||
accountInfo != nil
|
||||
|
@ -56,8 +57,9 @@ class MastodonController {
|
|||
self.transient = transient
|
||||
}
|
||||
|
||||
func run<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) {
|
||||
client.run(request, completion: completion)
|
||||
@discardableResult
|
||||
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) {
|
||||
|
@ -95,7 +97,9 @@ class MastodonController {
|
|||
completion?(.failure(error))
|
||||
|
||||
case let .success(account, _):
|
||||
self.account = account
|
||||
DispatchQueue.main.async {
|
||||
self.account = account
|
||||
}
|
||||
self.persistentContainer.backgroundContext.perform {
|
||||
if let accountMO = self.persistentContainer.account(for: account.id, in: self.persistentContainer.backgroundContext) {
|
||||
accountMO.updateFrom(apiAccount: account, container: self.persistentContainer)
|
||||
|
@ -118,13 +122,28 @@ class MastodonController {
|
|||
let request = Client.getInstance()
|
||||
run(request) { (response) in
|
||||
guard case let .success(instance, _) = response else { fatalError() }
|
||||
self.instance = instance
|
||||
completion?(instance)
|
||||
DispatchQueue.main.async {
|
||||
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 {}
|
||||
|
|
|
@ -26,6 +26,7 @@ class MastodonCachePersistentStore: NSPersistentContainer {
|
|||
|
||||
let statusSubject = PassthroughSubject<String, Never>()
|
||||
let accountSubject = PassthroughSubject<String, Never>()
|
||||
let relationshipSubject = PassthroughSubject<String, Never>()
|
||||
|
||||
init(for accountInfo: LocalData.UserAccountInfo?, transient: Bool = false) {
|
||||
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) {
|
||||
backgroundContext.perform {
|
||||
accounts.forEach { self.upsert(account: $0, incrementReferenceCount: true) }
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
<?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">
|
||||
<attribute name="acct" attributeType="String"/>
|
||||
<attribute name="avatar" attributeType="URI"/>
|
||||
|
@ -20,12 +20,26 @@
|
|||
<attribute name="url" attributeType="URI"/>
|
||||
<attribute name="username" attributeType="String"/>
|
||||
<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>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</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">
|
||||
<attribute name="applicationName" optional="YES" attributeType="String"/>
|
||||
<attribute name="attachmentsData" attributeType="Binary"/>
|
||||
|
@ -59,7 +73,8 @@
|
|||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<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="Relationship" positionX="63" positionY="135" width="128" height="208"/>
|
||||
</elements>
|
||||
</model>
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -81,6 +81,10 @@
|
|||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
|
|
|
@ -51,7 +51,10 @@ enum CompositionAttachmentData {
|
|||
func getData(completion: @escaping (_ data: Data, _ mimeType: String) -> Void) {
|
||||
switch self {
|
||||
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):
|
||||
if asset.mediaType == .image {
|
||||
let options = PHImageRequestOptions()
|
||||
|
|
|
@ -29,6 +29,12 @@ class Draft: Codable, ObservableObject {
|
|||
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) {
|
||||
self.id = UUID()
|
||||
self.lastModified = Date()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -158,15 +158,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
mastodonController.getOwnInstance()
|
||||
|
||||
let rootController: UIViewController
|
||||
#if SDK_IOS_14
|
||||
if #available(iOS 14.0, *) {
|
||||
rootController = MainSplitViewController(mastodonController: mastodonController)
|
||||
} else {
|
||||
rootController = MainTabBarViewController(mastodonController: mastodonController)
|
||||
}
|
||||
#else
|
||||
rootController = MainTabBarViewController(mastodonController: mastodonController)
|
||||
#endif
|
||||
window!.rootViewController = rootController
|
||||
}
|
||||
|
||||
|
|
|
@ -8,9 +8,18 @@
|
|||
|
||||
import UIKit
|
||||
import AVKit
|
||||
import Pachyderm
|
||||
|
||||
class GalleryPlayerViewController: AVPlayerViewController {
|
||||
|
||||
var attachment: Attachment!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
allowsPictureInPicturePlayback = true
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
|
|
|
@ -12,11 +12,13 @@ import AVKit
|
|||
|
||||
class GalleryViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, LargeImageAnimatableViewController {
|
||||
|
||||
weak var avPlayerViewControllerDelegate: AVPlayerViewControllerDelegate?
|
||||
|
||||
let attachments: [Attachment]
|
||||
let sourceViews: WeakArray<UIImageView>
|
||||
let startIndex: Int
|
||||
|
||||
let pages: [UIViewController]
|
||||
var pages: [UIViewController]!
|
||||
|
||||
var currentIndex: Int {
|
||||
guard let vc = viewControllers?.first,
|
||||
|
@ -72,6 +74,18 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
|
|||
self.sourceViews = WeakArray(sourceViews)
|
||||
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
|
||||
switch attachment.kind {
|
||||
case .image:
|
||||
|
@ -82,6 +96,8 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
|
|||
case .video, .audio:
|
||||
let vc = GalleryPlayerViewController()
|
||||
vc.player = AVPlayer(url: attachment.url)
|
||||
vc.delegate = avPlayerViewControllerDelegate
|
||||
vc.attachment = attachment
|
||||
return vc
|
||||
case .gifv:
|
||||
// Passing the source view to the LargeImageGifvContentView is a crappy workaround for not
|
||||
|
@ -101,20 +117,8 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
|
|||
}
|
||||
}
|
||||
|
||||
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal)
|
||||
|
||||
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.delegate = self
|
||||
|
||||
|
|
|
@ -91,7 +91,9 @@ struct ComposeAttachmentsList: View {
|
|||
}
|
||||
|
||||
private var canAddAttachment: Bool {
|
||||
switch mastodonController.instance.instanceType {
|
||||
switch mastodonController.instance?.instanceType {
|
||||
case nil:
|
||||
return false
|
||||
case .pleroma:
|
||||
return true
|
||||
case .mastodon:
|
||||
|
|
|
@ -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"))
|
||||
}
|
||||
}
|
|
@ -9,7 +9,7 @@
|
|||
import SwiftUI
|
||||
|
||||
struct ComposeAvatarImageView: View {
|
||||
let url: URL
|
||||
let url: URL?
|
||||
@State var request: ImageCache.Request? = nil
|
||||
@State var avatarImage: UIImage? = nil
|
||||
@ObservedObject var preferences = Preferences.shared
|
||||
|
@ -17,34 +17,43 @@ struct ComposeAvatarImageView: View {
|
|||
var body: some View {
|
||||
image
|
||||
.resizable()
|
||||
.frame(width: 50, height: 50)
|
||||
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
|
||||
.onAppear(perform: self.loadImage)
|
||||
.conditionally(url != nil) {
|
||||
$0.onAppear(perform: self.loadImage)
|
||||
}
|
||||
.onDisappear(perform: self.cancelRequest)
|
||||
}
|
||||
|
||||
private var image: Image {
|
||||
if let avatarImage = avatarImage {
|
||||
return Image(uiImage: avatarImage)
|
||||
return Image(uiImage: avatarImage).renderingMode(.original)
|
||||
} else {
|
||||
let imageName: String
|
||||
switch preferences.avatarStyle {
|
||||
case .circle:
|
||||
imageName = "person.crop.circle"
|
||||
case .roundRect:
|
||||
imageName = "person.crop.square"
|
||||
}
|
||||
return Image(systemName: imageName)
|
||||
return placeholderImage
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
guard let url = url else { return }
|
||||
request = ImageCache.avatars.get(url) { (data) in
|
||||
DispatchQueue.main.async {
|
||||
self.request = nil
|
||||
if let data = data, let image = UIImage(data: data) {
|
||||
if let data = data, let image = UIImage(data: data) {
|
||||
DispatchQueue.main.async {
|
||||
self.request = nil
|
||||
self.avatarImage = image
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
self.request = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -11,25 +11,33 @@ import Pachyderm
|
|||
|
||||
struct ComposeCurrentAccount: View {
|
||||
@EnvironmentObject var mastodonController: MastodonController
|
||||
@ObservedObject private var preferences = Preferences.shared
|
||||
|
||||
var account: Account {
|
||||
mastodonController.account!
|
||||
var account: Account? {
|
||||
mastodonController.account
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
ComposeAvatarImageView(url: account.avatar)
|
||||
.accessibility(label: Text("\(account.displayName) avatar"))
|
||||
ComposeAvatarImageView(url: account?.avatar)
|
||||
.frame(width: 50, height: 50)
|
||||
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
|
||||
.accessibility(label: Text(account != nil ? "\(account!.displayName) avatar" : "Avatar"))
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
AccountDisplayNameLabel(account: mastodonController.persistentContainer.account(for: account.id)!, fontSize: 20)
|
||||
.lineLimit(1)
|
||||
if let id = account?.id,
|
||||
let account = mastodonController.persistentContainer.account(for: id) {
|
||||
VStack(alignment: .leading) {
|
||||
AccountDisplayNameLabel(account: account, fontSize: 20)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(verbatim: "@\(account.acct)")
|
||||
.font(.system(size: 17, weight: .light))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
Text(verbatim: "@\(account.acct)")
|
||||
.font(.system(size: 17, weight: .light))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -58,15 +58,11 @@ class ComposeDrawingViewController: UIViewController {
|
|||
canvasView.drawing = initialDrawing
|
||||
}
|
||||
canvasView.delegate = self
|
||||
#if SDK_IOS_14
|
||||
if #available(iOS 14.0, *) {
|
||||
canvasView.drawingPolicy = .anyInput
|
||||
} else {
|
||||
canvasView.allowsFingerDrawing = true
|
||||
}
|
||||
#else
|
||||
canvasView.allowsFingerDrawing = true
|
||||
#endif
|
||||
canvasView.minimumZoomScale = 0.5
|
||||
canvasView.maximumZoomScale = 2
|
||||
canvasView.backgroundColor = .systemBackground
|
||||
|
|
|
@ -164,7 +164,9 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
|
|||
let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
|
||||
// temporarily reset add'l safe area insets so we can access the default inset
|
||||
additionalSafeAreaInsets = .zero
|
||||
keyboardHeight = frame.height - view.safeAreaInsets.bottom - accessoryView.frame.height
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,6 +15,8 @@ struct ComposeReplyView: View {
|
|||
|
||||
@State private var contentHeight: CGFloat?
|
||||
|
||||
@ObservedObject private var preferences = Preferences.shared
|
||||
|
||||
private let horizSpacing: CGFloat = 8
|
||||
|
||||
var body: some View {
|
||||
|
@ -55,6 +57,8 @@ struct ComposeReplyView: View {
|
|||
scrollOffset += stackPadding
|
||||
let offset = min(max(scrollOffset, 0), geometry.size.height - 50 - stackPadding)
|
||||
return ComposeAvatarImageView(url: status.account.avatar)
|
||||
.frame(width: 50, height: 50)
|
||||
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
|
||||
.offset(x: 0, y: offset)
|
||||
}
|
||||
|
||||
|
|
|
@ -27,13 +27,7 @@ struct ComposeTextView: View {
|
|||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
WrappedTextView(
|
||||
text: $text,
|
||||
textDidChange: self.textDidChange,
|
||||
backgroundColor: backgroundColor,
|
||||
font: .systemFont(ofSize: fontSize)
|
||||
)
|
||||
.frame(height: height ?? minHeight)
|
||||
Color(backgroundColor)
|
||||
|
||||
if text.isEmpty, let placeholder = placeholder {
|
||||
placeholder
|
||||
|
@ -41,6 +35,13 @@ struct ComposeTextView: View {
|
|||
.foregroundColor(.secondary)
|
||||
.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
|
||||
var textDidChange: ((UITextView) -> Void)?
|
||||
var backgroundColor = UIColor.secondarySystemBackground
|
||||
var font = UIFont.systemFont(ofSize: 20)
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let textView = UITextView()
|
||||
textView.delegate = context.coordinator
|
||||
textView.isEditable = true
|
||||
textView.backgroundColor = backgroundColor
|
||||
textView.backgroundColor = .clear
|
||||
textView.font = font
|
||||
textView.textContainer.lineBreakMode = .byWordWrapping
|
||||
return textView
|
||||
|
|
|
@ -27,9 +27,12 @@ class ComposeUIState: ObservableObject {
|
|||
@Published var draft: Draft
|
||||
@Published var isShowingSaveDraftSheet = false
|
||||
@Published var attachmentsMissingDescriptions = Set<UUID>()
|
||||
@Published var autocompleteState: AutocompleteState? = nil
|
||||
|
||||
var composeDrawingMode: ComposeDrawingMode?
|
||||
|
||||
weak var autocompleteHandler: ComposeAutocompleteHandler?
|
||||
|
||||
init(draft: Draft) {
|
||||
self.draft = draft
|
||||
}
|
||||
|
@ -42,3 +45,15 @@ extension ComposeUIState {
|
|||
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)
|
||||
}
|
||||
|
|
|
@ -28,7 +28,7 @@ struct ComposeView: View {
|
|||
}
|
||||
|
||||
var charactersRemaining: Int {
|
||||
let limit = mastodonController.instance.maxStatusCharacters ?? 500
|
||||
let limit = mastodonController.instance?.maxStatusCharacters ?? 500
|
||||
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
|
||||
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
|
||||
WrappedProgressView(value: postProgress, total: postTotalProgress)
|
||||
|
||||
autocompleteSuggestions
|
||||
}
|
||||
.onAppear(perform: self.didAppear)
|
||||
.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 {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let id = draft.inReplyToID,
|
||||
|
@ -92,8 +116,7 @@ struct ComposeView: View {
|
|||
header
|
||||
|
||||
if draft.contentWarningEnabled {
|
||||
TextField("Write your warning here", text: $draft.contentWarning)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
ComposeContentWarningTextField(text: $draft.contentWarning)
|
||||
}
|
||||
|
||||
MainComposeTextView(
|
||||
|
@ -108,6 +131,7 @@ struct ComposeView: View {
|
|||
.padding([.top, .bottom], -8)
|
||||
}
|
||||
.padding(stackPadding)
|
||||
.padding(.bottom, uiState.autocompleteState != nil ? 46 : nil)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
|
@ -197,7 +221,7 @@ struct ComposeView: View {
|
|||
self.isPosting = false
|
||||
|
||||
case let .success(uploadedAttachments):
|
||||
let request = Client.createStatus(text: draft.text,
|
||||
let request = Client.createStatus(text: draft.textForPosting,
|
||||
contentType: Preferences.shared.statusContentType,
|
||||
inReplyTo: draft.inReplyToID,
|
||||
media: uploadedAttachments,
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -20,14 +20,7 @@ struct MainComposeTextView: View {
|
|||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
MainComposeWrappedTextView(
|
||||
text: $draft.text,
|
||||
visibility: draft.visibility,
|
||||
becomeFirstResponder: $becomeFirstResponder
|
||||
) { (textView) in
|
||||
self.height = max(textView.contentSize.height, minHeight)
|
||||
}
|
||||
.frame(height: height ?? minHeight)
|
||||
Color(UIColor.secondarySystemBackground)
|
||||
|
||||
if draft.text.isEmpty {
|
||||
placeholder
|
||||
|
@ -35,7 +28,17 @@ struct MainComposeTextView: View {
|
|||
.foregroundColor(.secondary)
|
||||
.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 {
|
||||
hasFirstAppeared = true
|
||||
becomeFirstResponder = true
|
||||
|
@ -59,11 +62,13 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
|||
let textView = UITextView()
|
||||
textView.delegate = context.coordinator
|
||||
textView.isEditable = true
|
||||
textView.backgroundColor = .secondarySystemBackground
|
||||
textView.backgroundColor = .clear
|
||||
textView.font = .systemFont(ofSize: 20)
|
||||
textView.textContainer.lineBreakMode = .byWordWrapping
|
||||
context.coordinator.textView = textView
|
||||
|
||||
uiState.autocompleteHandler = context.coordinator
|
||||
|
||||
let visibilityAction: Selector?
|
||||
if #available(iOS 14.0, *) {
|
||||
visibilityAction = nil
|
||||
|
@ -147,27 +152,25 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
|||
context.coordinator.didChange = textDidChange
|
||||
context.coordinator.uiState = uiState
|
||||
|
||||
if becomeFirstResponder {
|
||||
DispatchQueue.main.async {
|
||||
// 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)
|
||||
|
||||
if becomeFirstResponder {
|
||||
// calling becomeFirstResponder during the SwiftUI update causes a crash on iOS 13
|
||||
uiView.becomeFirstResponder()
|
||||
// can't update @State vars during the SwiftUI update
|
||||
becomeFirstResponder = false
|
||||
}
|
||||
}
|
||||
|
||||
// wait until the next runloop iteration so that SwiftUI view updates have finished and
|
||||
// the text view knows its new content size
|
||||
DispatchQueue.main.async {
|
||||
self.textDidChange(uiView)
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
return Coordinator(text: $text, uiState: uiState, didChange: textDidChange)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UITextViewDelegate {
|
||||
class Coordinator: NSObject, UITextViewDelegate, ComposeAutocompleteHandler {
|
||||
weak var textView: UITextView?
|
||||
var text: Binding<String>
|
||||
var didChange: (UITextView) -> Void
|
||||
|
@ -213,5 +216,146 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
|||
@objc func keyboardDidHide(_ notification: Foundation.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,6 +52,8 @@ class ConversationTableViewController: EnhancedTableViewController {
|
|||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
title = NSLocalizedString("Conversation", comment: "conversation screen title")
|
||||
|
||||
tableView.delegate = self
|
||||
tableView.dataSource = self
|
||||
|
||||
|
|
|
@ -13,11 +13,18 @@ class AddSavedHashtagViewController: SearchResultsViewController {
|
|||
|
||||
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() {
|
||||
super.viewDidLoad()
|
||||
|
||||
delegate = self
|
||||
onlySections = [.hashtags]
|
||||
|
||||
searchController = UISearchController(searchResultsController: nil)
|
||||
searchController.obscuresBackgroundDuringPresentation = false
|
||||
|
|
|
@ -133,9 +133,13 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
centerImage()
|
||||
|
||||
// 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
|
||||
// on iPhone XR, 11, the top inset is 48pts
|
||||
if view.safeAreaInsets.top == 44 || view.safeAreaInsets.top == 48 {
|
||||
let notchedDeviceTopInsets: [CGFloat] = [
|
||||
44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max
|
||||
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 earWidth = (view.bounds.width - notchWidth) / 2
|
||||
let offset = (earWidth - shareButton.bounds.width) / 2
|
||||
|
|
|
@ -20,9 +20,7 @@ class LargeImageInteractionController: UIPercentDrivenInteractiveTransition {
|
|||
super.init()
|
||||
self.viewController = viewController
|
||||
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handleGesture(_:)))
|
||||
if #available(iOS 13.4, *) {
|
||||
panRecognizer.allowedScrollTypesMask = .all
|
||||
}
|
||||
panRecognizer.allowedScrollTypesMask = .all
|
||||
viewController.view.addGestureRecognizer(panRecognizer)
|
||||
}
|
||||
|
||||
|
|
|
@ -52,9 +52,8 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
|||
})
|
||||
dataSource.editListAccountsController = self
|
||||
|
||||
searchResultsController = SearchResultsViewController(mastodonController: mastodonController)
|
||||
searchResultsController = SearchResultsViewController(mastodonController: mastodonController, resultTypes: [.accounts])
|
||||
searchResultsController.delegate = self
|
||||
searchResultsController.onlySections = [.accounts]
|
||||
searchController = UISearchController(searchResultsController: searchResultsController)
|
||||
searchController.hidesNavigationBarDuringPresentation = false
|
||||
searchController.searchResultsUpdater = searchResultsController
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
#if SDK_IOS_14
|
||||
@available(iOS 14.0, *)
|
||||
protocol MainSidebarViewControllerDelegate: class {
|
||||
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
|
||||
|
@ -380,4 +379,3 @@ extension MainSidebarViewController: InstanceTimelineViewControllerDelegate {
|
|||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
import UIKit
|
||||
|
||||
#if SDK_IOS_14
|
||||
@available(iOS 14.0, *)
|
||||
class MainSplitViewController: UISplitViewController {
|
||||
|
||||
|
@ -21,6 +20,14 @@ class MainSplitViewController: UISplitViewController {
|
|||
|
||||
private var tabBarViewController: MainTabBarViewController!
|
||||
|
||||
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
if UIDevice.current.userInterfaceIdiom == .phone {
|
||||
return .portrait
|
||||
} else {
|
||||
return .all
|
||||
}
|
||||
}
|
||||
|
||||
init(mastodonController: MastodonController) {
|
||||
self.mastodonController = mastodonController
|
||||
|
||||
|
@ -316,11 +323,15 @@ extension MainSplitViewController: TuskerRootViewController {
|
|||
}
|
||||
|
||||
func select(tab: MainTabBarViewController.Tab) {
|
||||
if tab == .compose {
|
||||
presentCompose()
|
||||
if traitCollection.horizontalSizeClass == .compact {
|
||||
tabBarViewController?.select(tab: tab)
|
||||
} else {
|
||||
select(item: .tab(tab))
|
||||
if tab == .compose {
|
||||
presentCompose()
|
||||
} else {
|
||||
select(item: .tab(tab))
|
||||
sidebar.select(item: .tab(tab), animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
@ -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: {
|
||||
NotificationCenter.default.post(name: .addAccount, object: nil)
|
||||
}) {
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
class MyProfileViewController: ProfileViewController {
|
||||
|
||||
|
@ -21,19 +22,8 @@ class MyProfileViewController: ProfileViewController {
|
|||
|
||||
DispatchQueue.main.async {
|
||||
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()
|
||||
|
||||
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() {
|
||||
present(PreferencesNavigationController(mastodonController: mastodonController), animated: true)
|
||||
}
|
||||
|
|
|
@ -14,11 +14,17 @@ class ProfileViewController: UIPageViewController {
|
|||
|
||||
weak var mastodonController: MastodonController!
|
||||
|
||||
// todo: does this still need to be settable?
|
||||
var accountID: String! {
|
||||
// This property is optional because MyProfileViewController may not have the user's account ID
|
||||
// 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 {
|
||||
updateAccountUI()
|
||||
pageControllers.forEach { $0.accountID = accountID }
|
||||
loadAccount()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -50,7 +56,9 @@ class ProfileViewController: UIPageViewController {
|
|||
}
|
||||
|
||||
deinit {
|
||||
mastodonController.persistentContainer.account(for: accountID)?.decrementReferenceCount()
|
||||
if let accountID = accountID {
|
||||
mastodonController.persistentContainer.account(for: accountID)?.decrementReferenceCount()
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
|
@ -84,8 +92,12 @@ class ProfileViewController: UIPageViewController {
|
|||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] (_) in self?.updateAccountUI() }
|
||||
|
||||
loadAccount()
|
||||
}
|
||||
|
||||
private func loadAccount() {
|
||||
guard let accountID = accountID else { return }
|
||||
if mastodonController.persistentContainer.account(for: accountID) != nil {
|
||||
headerView.updateUI(for: accountID)
|
||||
updateAccountUI()
|
||||
} else {
|
||||
let req = Client.getAccount(id: accountID)
|
||||
|
@ -95,10 +107,6 @@ class ProfileViewController: UIPageViewController {
|
|||
self.mastodonController.persistentContainer.addOrUpdate(account: account, incrementReferenceCount: true) { (account) in
|
||||
DispatchQueue.main.async {
|
||||
self.updateAccountUI()
|
||||
self.headerView.updateUI(for: self.accountID)
|
||||
self.pageControllers.forEach {
|
||||
$0.updateUI(account: account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -106,8 +114,17 @@ class ProfileViewController: UIPageViewController {
|
|||
}
|
||||
|
||||
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
|
||||
pageControllers.forEach {
|
||||
$0.updateUI(account: account)
|
||||
}
|
||||
}
|
||||
|
||||
private func selectPage(at index: Int, animated: Bool, completion: ((Bool) -> Void)? = nil) {
|
||||
|
@ -177,13 +194,15 @@ class ProfileViewController: UIPageViewController {
|
|||
// MARK: Interaction
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
draft.visibility = .direct
|
||||
compose(editing: draft)
|
||||
|
|
|
@ -35,15 +35,17 @@ class SearchResultsViewController: EnhancedTableViewController {
|
|||
|
||||
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>()
|
||||
var currentQuery: String?
|
||||
|
||||
init(mastodonController: MastodonController) {
|
||||
init(mastodonController: MastodonController, resultTypes: [SearchResultType]? = nil) {
|
||||
self.mastodonController = mastodonController
|
||||
self.resultTypes = resultTypes
|
||||
|
||||
super.init(style: .grouped)
|
||||
|
||||
|
@ -128,7 +130,7 @@ class SearchResultsViewController: EnhancedTableViewController {
|
|||
activityIndicator.isHidden = false
|
||||
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
|
||||
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.appendItems(results.accounts.map { .account($0.id) }, toSection: .accounts)
|
||||
addAccounts(results.accounts)
|
||||
}
|
||||
if self.onlySections.contains(.hashtags) && !results.hashtags.isEmpty {
|
||||
if !results.hashtags.isEmpty {
|
||||
snapshot.appendSections([.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.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses)
|
||||
addStatuses(results.statuses)
|
||||
|
|
|
@ -36,11 +36,9 @@ class InteractivePushTransition: UIPercentDrivenInteractiveTransition {
|
|||
interactivePushGestureRecognizer.require(toFail: navigationController.interactivePopGestureRecognizer!)
|
||||
navigationController.view.addGestureRecognizer(interactivePushGestureRecognizer)
|
||||
|
||||
if #available(iOS 13.4, *) {
|
||||
let trackpadGestureRecognizer = TrackpadScrollGestureRecognizer(target: self, action: #selector(handleSwipeForward(_:)))
|
||||
trackpadGestureRecognizer.require(toFail: navigationController.interactivePopGestureRecognizer!)
|
||||
navigationController.view.addGestureRecognizer(trackpadGestureRecognizer)
|
||||
}
|
||||
let trackpadGestureRecognizer = TrackpadScrollGestureRecognizer(target: self, action: #selector(handleSwipeForward(_:)))
|
||||
trackpadGestureRecognizer.require(toFail: navigationController.interactivePopGestureRecognizer!)
|
||||
navigationController.view.addGestureRecognizer(trackpadGestureRecognizer)
|
||||
}
|
||||
|
||||
@objc func handleSwipeForward(_ recognizer: UIPanGestureRecognizer) {
|
||||
|
|
|
@ -54,8 +54,6 @@ extension MenuPreviewProvider {
|
|||
}),
|
||||
]
|
||||
|
||||
// todo: handle pre-iOS 14
|
||||
#if SDK_IOS_14
|
||||
if accountID != mastodonController.account.id,
|
||||
#available(iOS 14.0, *) {
|
||||
actionsSection.append(UIDeferredMenuElement({ (elementHandler) in
|
||||
|
@ -71,9 +69,15 @@ extension MenuPreviewProvider {
|
|||
let following = relationship.following
|
||||
DispatchQueue.main.async {
|
||||
elementHandler([
|
||||
self.createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.minus", handler: { (_) in
|
||||
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)
|
||||
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 = [
|
||||
openInSafariAction(url: account.url),
|
||||
|
|
|
@ -47,8 +47,9 @@ enum AppShortcutItem: String, CaseIterable {
|
|||
}
|
||||
let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first!
|
||||
let window = scene.windows.first { $0.isKeyWindow }!
|
||||
let controller = window.rootViewController as! MainTabBarViewController
|
||||
controller.select(tab: tab)
|
||||
if let controller = window.rootViewController as? TuskerRootViewController {
|
||||
controller.select(tab: tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,16 +7,17 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
|
||||
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
||||
|
||||
struct AccountDisplayNameLabel: View {
|
||||
let account: AccountMO
|
||||
struct AccountDisplayNameLabel<Account: AccountProtocol>: View {
|
||||
let account: Account
|
||||
let fontSize: Int
|
||||
@State var text: Text
|
||||
@State var emojiRequests = [ImageCache.Request]()
|
||||
|
||||
init(account: AccountMO, fontSize: Int) {
|
||||
init(account: Account, fontSize: Int) {
|
||||
self.account = account
|
||||
self.fontSize = fontSize
|
||||
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)
|
||||
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()
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ import Gifu
|
|||
import AVFoundation
|
||||
|
||||
protocol AttachmentViewDelegate: class {
|
||||
func attachmentViewGallery(startingAt index: Int) -> UIViewController?
|
||||
func attachmentViewGallery(startingAt index: Int) -> GalleryViewController?
|
||||
func attachmentViewPresent(_ vc: UIViewController, animated: Bool)
|
||||
}
|
||||
|
||||
|
|
|
@ -58,11 +58,11 @@ class AttachmentsContainerView: UIView {
|
|||
attachmentViews.removeAllObjects()
|
||||
moreView?.removeFromSuperview()
|
||||
|
||||
var accessibilityElements = [Any]()
|
||||
|
||||
if attachments.count > 0 {
|
||||
self.isHidden = false
|
||||
|
||||
var accessibilityElements = [Any]()
|
||||
|
||||
switch attachments.count {
|
||||
case 1:
|
||||
let attachmentView = createAttachmentView(index: 0, hSize: .full, vSize: .full)
|
||||
|
@ -215,13 +215,16 @@ class AttachmentsContainerView: UIView {
|
|||
accessibilityElements.append(topRight)
|
||||
accessibilityElements.append(bottomLeft)
|
||||
accessibilityElements.append(moreView)
|
||||
}
|
||||
|
||||
self.accessibilityElements = accessibilityElements
|
||||
}
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
|
@ -51,7 +51,7 @@ class ContentTextView: LinkTextView {
|
|||
func setEmojis(_ emojis: [Emoji]) {
|
||||
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()
|
||||
|
||||
|
|
|
@ -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()
|
||||
// }
|
||||
//}
|
|
@ -9,12 +9,12 @@
|
|||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
||||
class EmojiLabel: UILabel, BaseEmojiLabel {
|
||||
|
||||
class EmojiLabel: UILabel {
|
||||
|
||||
private var emojiIdentifier: String?
|
||||
private var emojiRequests: [ImageCache.Request] = []
|
||||
var emojiIdentifier: String?
|
||||
var emojiRequests: [ImageCache.Request] = []
|
||||
var emojiFont: UIFont { font }
|
||||
var emojiTextColor: UIColor { textColor }
|
||||
|
||||
func setEmojis(_ emojis: [Emoji], identifier: String) {
|
||||
guard emojis.count > 0, let attributedText = attributedText else { return }
|
||||
|
@ -23,53 +23,9 @@ class EmojiLabel: UILabel {
|
|||
emojiRequests.forEach { $0.cancel() }
|
||||
emojiRequests = []
|
||||
|
||||
let matches = emojiRegex.matches(in: attributedText.string, options: [], range: attributedText.fullRange)
|
||||
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
|
||||
replaceEmojis(in: attributedText.string, emojis: emojis, identifier: identifier) { [weak self] (newAttributedText) in
|
||||
guard let self = self, self.emojiIdentifier == identifier else { return }
|
||||
|
||||
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.attributedText = newAttributedText
|
||||
self.setNeedsLayout()
|
||||
self.setNeedsDisplay()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -19,7 +19,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
|
|||
@IBOutlet weak var verticalStackView: UIStackView!
|
||||
@IBOutlet weak var actionAvatarStackView: UIStackView!
|
||||
@IBOutlet weak var timestampLabel: UILabel!
|
||||
@IBOutlet weak var actionLabel: UILabel!
|
||||
@IBOutlet weak var actionLabel: MultiSourceEmojiLabel!
|
||||
@IBOutlet weak var statusContentLabel: UILabel!
|
||||
|
||||
var group: NotificationGroup!
|
||||
|
@ -35,14 +35,12 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
|
|||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
actionLabel.combiner = self.updateActionLabel
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
|
||||
}
|
||||
|
||||
@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 {
|
||||
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) })
|
||||
|
||||
updateTimestamp()
|
||||
|
||||
updateActionLabel(people: people)
|
||||
actionLabel.setEmojis(pairs: people.map { ($0.displayOrUserName, $0.emojis) }, identifier: group.id)
|
||||
|
||||
let doc = try! SwiftSoup.parse(status.content)
|
||||
statusContentLabel.text = try! doc.text()
|
||||
|
@ -135,7 +132,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
|
|||
}
|
||||
}
|
||||
|
||||
func updateActionLabel(people: [AccountMO]) {
|
||||
func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString {
|
||||
let verb: String
|
||||
switch group.kind {
|
||||
case .favourite:
|
||||
|
@ -145,18 +142,27 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
|
|||
default:
|
||||
fatalError()
|
||||
}
|
||||
let peopleStr: String
|
||||
|
||||
// todo: figure out how to localize this
|
||||
// todo: update to use managed objects
|
||||
switch people.count {
|
||||
let str = NSMutableAttributedString(string: "\(verb) by ")
|
||||
switch names.count {
|
||||
case 1:
|
||||
peopleStr = people.first!.displayName
|
||||
str.append(names.first!)
|
||||
case 2:
|
||||
peopleStr = people.first!.displayName + " and " + people.last!.displayName
|
||||
str.append(names.first!)
|
||||
str.append(NSAttributedString(string: " and "))
|
||||
str.append(names.last!)
|
||||
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() {
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
<?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"/>
|
||||
<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="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
|
@ -29,17 +31,17 @@
|
|||
</constraints>
|
||||
</stackView>
|
||||
<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>
|
||||
<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"/>
|
||||
<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"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</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"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<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">
|
||||
<rect key="frame" x="0.0" y="79" width="230" height="74"/>
|
||||
<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"/>
|
||||
</label>
|
||||
</subviews>
|
||||
|
@ -83,4 +85,9 @@
|
|||
<point key="canvasLocation" x="-394.20289855072468" y="56.584821428571423"/>
|
||||
</tableViewCell>
|
||||
</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>
|
||||
|
|
|
@ -16,7 +16,7 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
|
|||
|
||||
@IBOutlet weak var avatarStackView: UIStackView!
|
||||
@IBOutlet weak var timestampLabel: UILabel!
|
||||
@IBOutlet weak var actionLabel: UILabel!
|
||||
@IBOutlet weak var actionLabel: MultiSourceEmojiLabel!
|
||||
|
||||
var group: NotificationGroup!
|
||||
|
||||
|
@ -30,13 +30,12 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
|
|||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
actionLabel.combiner = self.updateActionLabel
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
|
||||
}
|
||||
|
||||
@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 {
|
||||
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) }
|
||||
|
||||
updateActionLabel(people: people)
|
||||
actionLabel.setEmojis(pairs: people.map {
|
||||
($0.displayOrUserName, $0.emojis)
|
||||
}, identifier: group.id)
|
||||
updateTimestamp()
|
||||
|
||||
avatarStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
|
@ -71,20 +72,27 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
|
|||
}
|
||||
}
|
||||
|
||||
func updateActionLabel(people: [AccountMO]) {
|
||||
// todo: custom emoji in people display names
|
||||
func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString {
|
||||
// todo: figure out how to localize this
|
||||
let peopleStr: String
|
||||
switch people.count {
|
||||
let str = NSMutableAttributedString(string: "Followed by ")
|
||||
switch names.count {
|
||||
case 1:
|
||||
peopleStr = people.first!.displayOrUserName
|
||||
str.append(names.first!)
|
||||
case 2:
|
||||
peopleStr = people.first!.displayOrUserName + " and " + people.last!.displayOrUserName
|
||||
str.append(names.first!)
|
||||
str.append(NSAttributedString(string: " and "))
|
||||
str.append(names.last!)
|
||||
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() {
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
<?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"/>
|
||||
<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="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
|
@ -29,17 +31,17 @@
|
|||
</constraints>
|
||||
</stackView>
|
||||
<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>
|
||||
<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"/>
|
||||
<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"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</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"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<nil key="textColor"/>
|
||||
|
@ -48,7 +50,7 @@
|
|||
</subviews>
|
||||
</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">
|
||||
<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>
|
||||
<constraint firstAttribute="width" constant="30" id="gvV-4g-2Xr"/>
|
||||
<constraint firstAttribute="height" constant="30" id="lS8-fq-ptY"/>
|
||||
|
@ -74,6 +76,9 @@
|
|||
</tableViewCell>
|
||||
</objects>
|
||||
<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>
|
||||
</document>
|
||||
|
|
|
@ -23,7 +23,11 @@ class ProfileHeaderView: UIView {
|
|||
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 }
|
||||
|
||||
@IBOutlet weak var headerImageView: UIImageView!
|
||||
|
@ -41,10 +45,10 @@ class ProfileHeaderView: UIView {
|
|||
|
||||
var accountID: String!
|
||||
|
||||
var avatarRequest: ImageCache.Request?
|
||||
var headerRequest: ImageCache.Request?
|
||||
private var avatarRequest: ImageCache.Request?
|
||||
private var headerRequest: ImageCache.Request?
|
||||
|
||||
private var accountUpdater: Cancellable?
|
||||
private var cancellables = [AnyCancellable]()
|
||||
|
||||
deinit {
|
||||
avatarRequest?.cancel()
|
||||
|
@ -67,15 +71,27 @@ class ProfileHeaderView: UIView {
|
|||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
|
||||
|
||||
if #available(iOS 13.4, *) {
|
||||
moreButton.addInteraction(UIPointerInteraction(delegate: self))
|
||||
}
|
||||
#if SDK_IOS_14
|
||||
moreButton.addInteraction(UIPointerInteraction(delegate: self))
|
||||
if #available(iOS 14.0, *) {
|
||||
moreButton.showsMenuAsPrimaryAction = 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) {
|
||||
|
@ -115,6 +131,9 @@ class ProfileHeaderView: UIView {
|
|||
|
||||
// don't show relationship label for the user's own account
|
||||
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])
|
||||
mastodonController.run(request) { [weak self] (response) in
|
||||
guard let self = self,
|
||||
|
@ -122,9 +141,7 @@ class ProfileHeaderView: UIView {
|
|||
let relationship = results.first else {
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.followsYouLabel.isHidden = !relationship.followedBy
|
||||
}
|
||||
self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -156,13 +173,14 @@ class ProfileHeaderView: UIView {
|
|||
|
||||
nameLabel.heightAnchor.constraint(equalTo: valueTextView.heightAnchor).isActive = true
|
||||
}
|
||||
}
|
||||
|
||||
if accountUpdater == nil {
|
||||
accountUpdater = mastodonController.persistentContainer.accountSubject
|
||||
.filter { [weak self] in $0 == self?.accountID }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] in self?.updateUI(for: $0) }
|
||||
private func updateRelationship() {
|
||||
guard let relationship = mastodonController.persistentContainer.relationship(forAccount: accountID) else {
|
||||
return
|
||||
}
|
||||
|
||||
followsYouLabel.isHidden = !relationship.followedBy
|
||||
}
|
||||
|
||||
@objc private func updateUIForPreferences() {
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
import UIKit
|
||||
import Pachyderm
|
||||
import Combine
|
||||
import AVKit
|
||||
|
||||
protocol StatusTableViewCellDelegate: TuskerNavigationDelegate {
|
||||
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell)
|
||||
|
@ -70,6 +71,8 @@ class BaseStatusTableViewCell: UITableViewCell {
|
|||
private var statusUpdater: Cancellable?
|
||||
private var accountUpdater: Cancellable?
|
||||
|
||||
private var currentPictureInPictureVideoStatusID: String?
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
|
@ -87,11 +90,9 @@ class BaseStatusTableViewCell: UITableViewCell {
|
|||
accessibilityElements = [displayNameLabel!, contentWarningLabel!, collapseButton!, contentTextView!, attachmentsView!]
|
||||
attachmentsView.isAccessibilityElement = true
|
||||
|
||||
#if SDK_IOS_14
|
||||
if #available(iOS 14.0, *) {
|
||||
moreButton.showsMenuAsPrimaryAction = true
|
||||
}
|
||||
#endif
|
||||
|
||||
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()
|
||||
|
||||
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||
fatalError("Missing cached status")
|
||||
}
|
||||
|
||||
self.statusID = statusID
|
||||
|
||||
doUpdateUI(status: status, state: state)
|
||||
}
|
||||
|
||||
func doUpdateUI(status: StatusMO, state: StatusState) {
|
||||
self.statusState = state
|
||||
|
||||
let account = status.account
|
||||
self.accountID = account.id
|
||||
updateUI(account: account)
|
||||
updateUIForPreferences(account: account)
|
||||
updateUIForPreferences(account: account, status: status)
|
||||
|
||||
attachmentsView.updateUI(status: status)
|
||||
attachmentsView.isAccessibilityElement = status.attachments.count > 0
|
||||
|
@ -194,12 +201,10 @@ class BaseStatusTableViewCell: UITableViewCell {
|
|||
reblogButton.accessibilityLabel = NSLocalizedString("Reblog", comment: "reblog button accessibility label")
|
||||
}
|
||||
|
||||
#if SDK_IOS_14
|
||||
if #available(iOS 14.0, *) {
|
||||
// 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))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func updateUI(account: AccountMO) {
|
||||
|
@ -213,18 +218,19 @@ class BaseStatusTableViewCell: UITableViewCell {
|
|||
}
|
||||
}
|
||||
|
||||
@objc func preferencesChanged() {
|
||||
@objc private func preferencesChanged() {
|
||||
guard let mastodonController = mastodonController,
|
||||
let account = mastodonController.persistentContainer.account(for: accountID),
|
||||
let status = mastodonController.persistentContainer.status(for: statusID) else { return }
|
||||
updateUIForPreferences(account: account)
|
||||
updateStatusIconsForPreferences(status)
|
||||
updateUIForPreferences(account: account, status: status)
|
||||
}
|
||||
|
||||
func updateUIForPreferences(account: AccountMO) {
|
||||
func updateUIForPreferences(account: AccountMO, status: StatusMO) {
|
||||
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
|
||||
displayNameLabel.updateForAccountDisplayName(account: account)
|
||||
attachmentsView.contentHidden = Preferences.shared.blurAllMedia || (mastodonController.persistentContainer.status(for: statusID)?.sensitive ?? false)
|
||||
|
||||
updateStatusIconsForPreferences(status)
|
||||
}
|
||||
|
||||
func updateStatusIconsForPreferences(_ status: StatusMO) {
|
||||
|
@ -357,15 +363,74 @@ class BaseStatusTableViewCell: UITableViewCell {
|
|||
}
|
||||
|
||||
extension BaseStatusTableViewCell: AttachmentViewDelegate {
|
||||
func attachmentViewGallery(startingAt index: Int) -> UIViewController? {
|
||||
func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? {
|
||||
guard let delegate = delegate,
|
||||
let status = mastodonController.persistentContainer.status(for: statusID) else { return nil }
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -38,9 +38,8 @@ class ConversationMainStatusTableViewCell: BaseStatusTableViewCell {
|
|||
contentTextView.defaultFont = .systemFont(ofSize: 18)
|
||||
}
|
||||
|
||||
override func updateUI(statusID: String, state: StatusState) {
|
||||
super.updateUI(statusID: statusID, state: state)
|
||||
guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError() }
|
||||
override func doUpdateUI(status: StatusMO, state: StatusState) {
|
||||
super.doUpdateUI(status: status, state: state)
|
||||
|
||||
var timestampAndClientText = ConversationMainStatusTableViewCell.dateFormatter.string(from: status.createdAt)
|
||||
if let application = status.applicationName {
|
||||
|
@ -63,8 +62,8 @@ class ConversationMainStatusTableViewCell: BaseStatusTableViewCell {
|
|||
profileAccessibilityElement.accessibilityLabel = account.displayNameWithoutCustomEmoji
|
||||
}
|
||||
|
||||
override func updateUIForPreferences(account: AccountMO) {
|
||||
super.updateUIForPreferences(account: account)
|
||||
override func updateUIForPreferences(account: AccountMO, status: StatusMO) {
|
||||
super.updateUIForPreferences(account: account, status: status)
|
||||
|
||||
favoriteAndReblogCountStackView.isHidden = !Preferences.shared.showFavoriteAndReblogCounts
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<?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"/>
|
||||
<dependencies>
|
||||
<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="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
|
@ -97,10 +97,10 @@
|
|||
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
||||
</textView>
|
||||
<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"/>
|
||||
<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>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ejU-sO-Og5">
|
||||
|
|
|
@ -68,10 +68,9 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
|
|||
}
|
||||
}
|
||||
|
||||
override func updateUI(statusID: String, state: StatusState) {
|
||||
guard var status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID)") }
|
||||
override func doUpdateUI(status: StatusMO, state: StatusState) {
|
||||
var status = status
|
||||
|
||||
let realStatusID: String
|
||||
if let rebloggedStatus = status.reblog {
|
||||
reblogStatusID = statusID
|
||||
rebloggerID = status.account.id
|
||||
|
@ -79,25 +78,24 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
|
|||
updateRebloggerLabel(reblogger: status.account)
|
||||
|
||||
status = rebloggedStatus
|
||||
realStatusID = rebloggedStatus.id
|
||||
statusID = rebloggedStatus.id
|
||||
} else {
|
||||
reblogStatusID = nil
|
||||
rebloggerID = nil
|
||||
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)
|
||||
timestampLabel.isHidden = pinned
|
||||
pinImageView.isHidden = !pinned
|
||||
}
|
||||
|
||||
@objc override func preferencesChanged() {
|
||||
super.preferencesChanged()
|
||||
override func updateUIForPreferences(account: AccountMO, status: StatusMO) {
|
||||
super.updateUIForPreferences(account: account, status: status)
|
||||
|
||||
if let rebloggerID = rebloggerID,
|
||||
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
|
||||
|
@ -121,12 +119,16 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
|
|||
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
|
||||
// so we bail out immediately, since there's nothing to update
|
||||
guard let mastodonController = mastodonController else { return }
|
||||
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.accessibilityLabel = TimelineStatusTableViewCell.relativeDateFormatter.localizedString(for: status.createdAt, relativeTo: Date())
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<?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"/>
|
||||
<dependencies>
|
||||
<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="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
|
@ -115,55 +115,14 @@
|
|||
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
||||
</textView>
|
||||
<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"/>
|
||||
<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>
|
||||
</view>
|
||||
</subviews>
|
||||
</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">
|
||||
<rect key="frame" x="0.0" y="54" width="50" height="22"/>
|
||||
<subviews>
|
||||
|
@ -186,20 +145,78 @@
|
|||
</imageView>
|
||||
</subviews>
|
||||
</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>
|
||||
<constraints>
|
||||
<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 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 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="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="Zlb-yt-NTw" firstAttribute="top" secondItem="gIY-Wp-RSk" secondAttribute="bottom" id="dOI-nF-1L9"/>
|
||||
<constraint firstItem="TUP-Nz-5Yh" firstAttribute="leading" secondItem="ve3-Y1-NQH" secondAttribute="leading" id="QZ2-iO-ckC"/>
|
||||
<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 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="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"/>
|
||||
</constraints>
|
||||
</view>
|
||||
|
|
|
@ -52,9 +52,7 @@ class VisualEffectImageButton: UIControl {
|
|||
imageView.bottomAnchor.constraint(equalTo: vibrancyView.bottomAnchor, constant: -2),
|
||||
])
|
||||
|
||||
#if SDK_IOS_14
|
||||
addInteraction(UIContextMenuInteraction(delegate: self))
|
||||
#endif
|
||||
|
||||
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(onTap)))
|
||||
}
|
||||
|
@ -63,12 +61,10 @@ class VisualEffectImageButton: UIControl {
|
|||
sendActions(for: .touchUpInside)
|
||||
}
|
||||
|
||||
#if SDK_IOS_14
|
||||
override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
|
||||
guard let menu = menu else { return nil }
|
||||
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) -> UIMenu? in
|
||||
return menu
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
|
@ -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>]
|
||||
|
||||
var startIndex: Int { array.startIndex }
|
||||
var endIndex: Int { array.endIndex }
|
||||
|
||||
init() {
|
||||
array = []
|
||||
}
|
||||
|
||||
init(_ elements: [Element]) {
|
||||
array = elements.map { WeakWrapper($0) }
|
||||
}
|
||||
|
@ -30,11 +34,20 @@ struct WeakArray<Element: AnyObject>: Collection {
|
|||
array = elements.map { WeakWrapper($0) }
|
||||
}
|
||||
|
||||
subscript(_ index: Int) -> Element? {
|
||||
return array[index].value
|
||||
subscript(position: Int) -> Element? {
|
||||
get {
|
||||
array[position].value
|
||||
}
|
||||
set(newValue) {
|
||||
array[position] = WeakWrapper(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
func index(after i: Int) -> Int {
|
||||
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) })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue