Compare commits

..

No commits in common. "1c871a12a118dfd4675cbc3524c8ea88682aafdf" and "14e8c11f02f872a72bd911d976425f1bf9365cd9" have entirely different histories.

76 changed files with 580 additions and 2249 deletions

View File

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

View File

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

View File

@ -18,7 +18,6 @@ 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
@ -30,6 +29,5 @@ public class Relationship: Decodable {
case followRequested = "requested"
case domainBlocking = "domain_blocking"
case showingReblogs = "showing_reblogs"
case endorsed
}
}

View File

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

View File

@ -140,7 +140,7 @@
D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC19123C271D9000D0238 /* MastodonActivity.swift */; };
D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AAC2128D88B005A6F37 /* LocalData.swift */; };
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; };
D64D8CA92463B494006B0BAA /* CachedDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* CachedDictionary.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,7 +160,6 @@
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 */; };
@ -174,6 +173,8 @@
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 */; };
@ -185,8 +186,6 @@
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 */; };
@ -241,9 +240,6 @@
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 */; };
@ -266,16 +262,9 @@
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 */; };
@ -476,7 +465,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 /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = "<group>"; };
D64D8CA82463B494006B0BAA /* CachedDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedDictionary.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>"; };
@ -500,7 +489,6 @@
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>"; };
@ -514,6 +502,8 @@
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>"; };
@ -525,8 +515,6 @@
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>"; };
@ -576,9 +564,6 @@
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>"; };
@ -607,16 +592,9 @@
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>"; };
@ -779,7 +757,6 @@
D61099F82145698900432DC2 /* Relationship.swift */,
D61099FA214569F600432DC2 /* Report.swift */,
D61099FC21456A1D00432DC2 /* SearchResults.swift */,
D6E426B8253382B300C02E1C /* SearchResultType.swift */,
D61099FE21456A4C00432DC2 /* Status.swift */,
D6285B4E21EA695800FE4B39 /* StatusContentType.swift */,
D6109A10214607D500432DC2 /* Timeline.swift */,
@ -907,7 +884,6 @@
D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */,
D60E2F232442372B005F8713 /* StatusMO.swift */,
D60E2F252442372B005F8713 /* AccountMO.swift */,
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */,
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
);
path = CoreData;
@ -1015,11 +991,6 @@
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>";
@ -1174,6 +1145,15 @@
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 = (
@ -1282,9 +1262,7 @@
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 */,
@ -1294,9 +1272,8 @@
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 */,
@ -1375,10 +1352,9 @@
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */,
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
D64D8CA82463B494006B0BAA /* CachedDictionary.swift */,
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
D67B506B250B28FF00FAECFB /* Vendor */,
D6F1F84E2193B9BE00F5FE67 /* Caching */,
D6757A7A2157E00100721E32 /* XCallbackURL */,
@ -1404,7 +1380,6 @@
children = (
D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */,
D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */,
D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */,
D6D4DDE6212518A200E1C4BB /* Info.plist */,
);
path = TuskerTests;
@ -1670,6 +1645,7 @@
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 */,
@ -1735,7 +1711,6 @@
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 */,
@ -1830,13 +1805,11 @@
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 */,
@ -1845,7 +1818,7 @@
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */,
D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */,
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
D6C143E025354E34007DC240 /* EmojiPickerCollectionViewController.swift in Sources */,
D67C57B421E2910700C3118B /* ComposeStatusReplyView.swift in Sources */,
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,
@ -1860,7 +1833,6 @@
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 */,
@ -1872,7 +1844,6 @@
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 */,
@ -1881,7 +1852,6 @@
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 */,
@ -1895,7 +1865,6 @@
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 */,
@ -1924,7 +1893,6 @@
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 */,
@ -1947,9 +1915,8 @@
D641C773213CAA25004B4513 /* NotificationsTableViewController.swift in Sources */,
D6412B0524B0227D00F5412E /* ProfileViewController.swift in Sources */,
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */,
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
D64D8CA92463B494006B0BAA /* CachedDictionary.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 */,
@ -1959,13 +1926,11 @@
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 */,
@ -1979,7 +1944,6 @@
buildActionMask = 2147483647;
files = (
D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */,
D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */,
D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -2265,7 +2229,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
@ -2294,7 +2258,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;

View File

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

View File

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

View File

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

View File

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

View File

@ -15,7 +15,6 @@ 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):

View File

@ -16,17 +16,9 @@ class ImageCache {
static let attachments = ImageCache(name: "Attachments", memoryExpiry: .seconds(60 * 2))
static let emojis = ImageCache(name: "Emojis", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60))
#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 = MultiThreadDictionary<URL, RequestGroup>(name: "ImageCache request groups")
private var backgroundQueue = DispatchQueue(label: "ImageCache completion queue", qos: .default)
private var groups = [URL: RequestGroup]()
init(name: String, memoryExpiry expiry: Expiry) {
let storage = MemoryStorage<Data>(config: MemoryConfig(expiry: expiry))
@ -46,15 +38,9 @@ class ImageCache {
func get(_ url: URL, completion: ((Data?) -> Void)?) -> Request? {
let key = url.absoluteString
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
if (try? cache.existsObject(forKey: key)) ?? false,
let data = try? cache.object(forKey: key) {
backgroundQueue.async {
completion?(data)
}
return nil
} else {
if let completion = completion, let group = groups[url] {
@ -64,7 +50,7 @@ class ImageCache {
if let data = data {
try? self.cache.setObject(data, forKey: key)
}
self.groups.removeValueWithoutReturning(forKey: url)
self.groups.removeValue(forKey: url)
}
groups[url] = group
let request = group.addCallback(completion)

View File

@ -9,7 +9,7 @@
import Foundation
import Pachyderm
class MastodonController: ObservableObject {
class MastodonController {
static private(set) var all = [LocalData.UserAccountInfo: MastodonController]()
@ -42,9 +42,8 @@ class MastodonController: ObservableObject {
let client: Client!
@Published private(set) var account: Account!
@Published private(set) var instance: Instance!
private(set) var customEmojis: [Emoji]?
var account: Account!
var instance: Instance!
var loggedIn: Bool {
accountInfo != nil
@ -57,9 +56,8 @@ class MastodonController: ObservableObject {
self.transient = transient
}
@discardableResult
func run<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) -> URLSessionTask? {
return client.run(request, completion: completion)
func run<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) {
client.run(request, completion: completion)
}
func registerApp(completion: @escaping (_ clientID: String, _ clientSecret: String) -> Void) {
@ -97,9 +95,7 @@ class MastodonController: ObservableObject {
completion?(.failure(error))
case let .success(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)
@ -122,28 +118,13 @@ class MastodonController: ObservableObject {
let request = Client.getInstance()
run(request) { (response) in
guard case let .success(instance, _) = response else { fatalError() }
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 {}

View File

@ -26,7 +26,6 @@ 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 {
@ -137,40 +136,6 @@ 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) }

View File

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

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17507" systemVersion="19G2021" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="16119" systemVersion="19D76" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="AccountMO" syncable="YES">
<attribute name="acct" attributeType="String"/>
<attribute name="avatar" attributeType="URI"/>
@ -20,26 +20,12 @@
<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"/>
@ -73,8 +59,7 @@
</uniquenessConstraints>
</entity>
<elements>
<element name="Account" positionX="169.21875" positionY="78.9609375" width="128" height="343"/>
<element name="Account" positionX="169.21875" positionY="78.9609375" width="128" height="328"/>
<element name="Status" positionX="-63" positionY="-18" width="128" height="418"/>
<element name="Relationship" positionX="63" positionY="135" width="128" height="208"/>
</elements>
</model>

View File

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

View File

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

View File

@ -51,10 +51,7 @@ enum CompositionAttachmentData {
func getData(completion: @escaping (_ data: Data, _ mimeType: String) -> Void) {
switch self {
case let .image(image):
// 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")
completion(image.pngData()!, "image/png")
case let .asset(asset):
if asset.mediaType == .image {
let options = PHImageRequestOptions()

View File

@ -29,12 +29,6 @@ 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()

View File

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

View File

@ -158,11 +158,15 @@ 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
}

View File

@ -8,18 +8,9 @@
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)

View File

@ -12,13 +12,11 @@ import AVKit
class GalleryViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, LargeImageAnimatableViewController {
weak var avPlayerViewControllerDelegate: AVPlayerViewControllerDelegate?
let attachments: [Attachment]
let sourceViews: WeakArray<UIImageView>
let startIndex: Int
var pages: [UIViewController]!
let pages: [UIViewController]
var currentIndex: Int {
guard let vc = viewControllers?.first,
@ -74,18 +72,6 @@ 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:
@ -96,8 +82,6 @@ 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
@ -117,8 +101,20 @@ 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

View File

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

View File

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

View File

@ -9,7 +9,7 @@
import SwiftUI
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,21 +17,16 @@ struct ComposeAvatarImageView: View {
var body: some View {
image
.resizable()
.conditionally(url != nil) {
$0.onAppear(perform: self.loadImage)
}
.frame(width: 50, height: 50)
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
.onAppear(perform: self.loadImage)
.onDisappear(perform: self.cancelRequest)
}
private var image: Image {
if let avatarImage = avatarImage {
return Image(uiImage: avatarImage).renderingMode(.original)
return Image(uiImage: avatarImage)
} else {
return placeholderImage
}
}
private var placeholderImage: Image {
let imageName: String
switch preferences.avatarStyle {
case .circle:
@ -41,19 +36,15 @@ struct ComposeAvatarImageView: View {
}
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) {
DispatchQueue.main.async {
self.request = nil
self.avatarImage = image
}
} else {
DispatchQueue.main.async {
self.request = nil
}
}
}
}

View File

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

View File

@ -11,23 +11,18 @@ 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)
.frame(width: 50, height: 50)
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
.accessibility(label: Text(account != nil ? "\(account!.displayName) avatar" : "Avatar"))
ComposeAvatarImageView(url: account.avatar)
.accessibility(label: Text("\(account.displayName) avatar"))
if let id = account?.id,
let account = mastodonController.persistentContainer.account(for: id) {
VStack(alignment: .leading) {
AccountDisplayNameLabel(account: account, fontSize: 20)
AccountDisplayNameLabel(account: mastodonController.persistentContainer.account(for: account.id)!, fontSize: 20)
.lineLimit(1)
Text(verbatim: "@\(account.acct)")
@ -36,9 +31,6 @@ struct ComposeCurrentAccount: View {
.lineLimit(1)
}
}
Spacer()
}
}
}

View File

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

View File

@ -164,9 +164,7 @@ 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
// 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
keyboardHeight = frame.height - view.safeAreaInsets.bottom - accessoryView.frame.height
updateAdditionalSafeAreaInsets()
}
}

View File

@ -15,8 +15,6 @@ struct ComposeReplyView: View {
@State private var contentHeight: CGFloat?
@ObservedObject private var preferences = Preferences.shared
private let horizSpacing: CGFloat = 8
var body: some View {
@ -57,8 +55,6 @@ 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)
}

View File

@ -27,7 +27,13 @@ struct ComposeTextView: View {
var body: some View {
ZStack(alignment: .topLeading) {
Color(backgroundColor)
WrappedTextView(
text: $text,
textDidChange: self.textDidChange,
backgroundColor: backgroundColor,
font: .systemFont(ofSize: fontSize)
)
.frame(height: height ?? minHeight)
if text.isEmpty, let placeholder = placeholder {
placeholder
@ -35,13 +41,6 @@ struct ComposeTextView: View {
.foregroundColor(.secondary)
.offset(x: 4, y: 8)
}
WrappedTextView(
text: $text,
textDidChange: self.textDidChange,
font: .systemFont(ofSize: fontSize)
)
.frame(height: height ?? minHeight)
}
}
@ -74,13 +73,14 @@ 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 = .clear
textView.backgroundColor = backgroundColor
textView.font = font
textView.textContainer.lineBreakMode = .byWordWrapping
return textView

View File

@ -27,12 +27,9 @@ 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
}
@ -45,15 +42,3 @@ 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)
}

View File

@ -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,8 +65,6 @@ 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")
@ -80,28 +78,6 @@ 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,
@ -116,7 +92,8 @@ struct ComposeView: View {
header
if draft.contentWarningEnabled {
ComposeContentWarningTextField(text: $draft.contentWarning)
TextField("Write your warning here", text: $draft.contentWarning)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
MainComposeTextView(
@ -131,7 +108,6 @@ struct ComposeView: View {
.padding([.top, .bottom], -8)
}
.padding(stackPadding)
.padding(.bottom, uiState.autocompleteState != nil ? 46 : nil)
}
private var header: some View {
@ -221,7 +197,7 @@ struct ComposeView: View {
self.isPosting = false
case let .success(uploadedAttachments):
let request = Client.createStatus(text: draft.textForPosting,
let request = Client.createStatus(text: draft.text,
contentType: Preferences.shared.statusContentType,
inReplyTo: draft.inReplyToID,
media: uploadedAttachments,

View File

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

View File

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

View File

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

View File

@ -20,15 +20,6 @@ struct MainComposeTextView: View {
var body: some View {
ZStack(alignment: .topLeading) {
Color(UIColor.secondarySystemBackground)
if draft.text.isEmpty {
placeholder
.font(.system(size: 20))
.foregroundColor(.secondary)
.offset(x: 4, y: 8)
}
MainComposeWrappedTextView(
text: $draft.text,
visibility: draft.visibility,
@ -36,9 +27,15 @@ struct MainComposeTextView: View {
) { (textView) in
self.height = max(textView.contentSize.height, minHeight)
}
}
.frame(height: height ?? minHeight)
.onAppear {
if draft.text.isEmpty {
placeholder
.font(.system(size: 20))
.foregroundColor(.secondary)
.offset(x: 4, y: 8)
}
}.onAppear {
if !hasFirstAppeared {
hasFirstAppeared = true
becomeFirstResponder = true
@ -62,13 +59,11 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
let textView = UITextView()
textView.delegate = context.coordinator
textView.isEditable = true
textView.backgroundColor = .clear
textView.backgroundColor = .secondarySystemBackground
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
@ -152,25 +147,27 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
context.coordinator.didChange = textDidChange
context.coordinator.uiState = uiState
// 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 {
DispatchQueue.main.async {
// calling becomeFirstResponder during the SwiftUI update causes a crash on iOS 13
uiView.becomeFirstResponder()
// can't update @State vars during the SwiftUI update
becomeFirstResponder = false
}
}
// wait until the next runloop iteration so that SwiftUI view updates have finished and
// the text view knows its new content size
DispatchQueue.main.async {
self.textDidChange(uiView)
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(text: $text, uiState: uiState, didChange: textDidChange)
}
class Coordinator: NSObject, UITextViewDelegate, ComposeAutocompleteHandler {
class Coordinator: NSObject, UITextViewDelegate {
weak var textView: UITextView?
var text: Binding<String>
var didChange: (UITextView) -> Void
@ -216,146 +213,5 @@ 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)
}
}
}

View File

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

View File

@ -13,18 +13,11 @@ 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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@
import UIKit
#if SDK_IOS_14
@available(iOS 14.0, *)
class MainSplitViewController: UISplitViewController {
@ -20,14 +21,6 @@ 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
@ -323,15 +316,11 @@ extension MainSplitViewController: TuskerRootViewController {
}
func select(tab: MainTabBarViewController.Tab) {
if traitCollection.horizontalSizeClass == .compact {
tabBarViewController?.select(tab: tab)
} else {
if tab == .compose {
presentCompose()
} else {
select(item: .tab(tab))
sidebar.select(item: .tab(tab), animated: false)
}
}
}
}
#endif

View File

@ -37,21 +37,7 @@ 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)
}) {

View File

@ -7,7 +7,6 @@
//
import UIKit
import Pachyderm
class MyProfileViewController: ProfileViewController {
@ -22,8 +21,19 @@ 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
}
})
}
}
@ -35,38 +45,8 @@ 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)
}

View File

@ -14,17 +14,11 @@ class ProfileViewController: UIPageViewController {
weak var mastodonController: MastodonController!
// 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")
}
}
// todo: does this still need to be settable?
var accountID: String! {
didSet {
updateAccountUI()
pageControllers.forEach { $0.accountID = accountID }
loadAccount()
}
}
@ -56,10 +50,8 @@ class ProfileViewController: UIPageViewController {
}
deinit {
if let accountID = accountID {
mastodonController.persistentContainer.account(for: accountID)?.decrementReferenceCount()
}
}
override func viewDidLoad() {
super.viewDidLoad()
@ -92,12 +84,8 @@ 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)
@ -107,6 +95,10 @@ 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)
}
}
}
}
@ -114,17 +106,8 @@ class ProfileViewController: UIPageViewController {
}
private func updateAccountUI() {
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)
guard let account = mastodonController.persistentContainer.account(for: accountID) else { return }
navigationItem.title = account.displayNameWithoutCustomEmoji
pageControllers.forEach {
$0.updateUI(account: account)
}
}
private func selectPage(at index: Int, animated: Bool, completion: ((Bool) -> Void)? = nil) {
@ -194,15 +177,13 @@ class ProfileViewController: UIPageViewController {
// MARK: Interaction
@objc private func composeMentioning() {
if let accountID = accountID,
let account = mastodonController.persistentContainer.account(for: accountID) {
if let account = mastodonController.persistentContainer.account(for: accountID) {
compose(mentioningAcct: account.acct)
}
}
private func composeDirectMentioning() {
if let accountID = accountID,
let account = mastodonController.persistentContainer.account(for: accountID) {
if let account = mastodonController.persistentContainer.account(for: accountID) {
let draft = mastodonController.createDraft(mentioningAcct: account.acct)
draft.visibility = .direct
compose(editing: draft)

View File

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

View File

@ -36,10 +36,12 @@ 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)
}
}
@objc func handleSwipeForward(_ recognizer: UIPanGestureRecognizer) {
interactive = true

View File

@ -54,6 +54,8 @@ 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
@ -69,15 +71,9 @@ 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.plus", handler: { (_) in
self.createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.minus", handler: { (_) in
let request = (following ? Account.unfollow : Account.follow)(accountID)
mastodonController.run(request) { (response) in
switch response {
case .failure(_):
fatalError()
case let .success(relationship, _):
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
}
mastodonController.run(request) { (_) in
}
})
])
@ -86,6 +82,7 @@ extension MenuPreviewProvider {
}
}))
}
#endif
let shareSection = [
openInSafariAction(url: account.url),

View File

@ -47,11 +47,10 @@ enum AppShortcutItem: String, CaseIterable {
}
let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first!
let window = scene.windows.first { $0.isKeyWindow }!
if let controller = window.rootViewController as? TuskerRootViewController {
let controller = window.rootViewController as! MainTabBarViewController
controller.select(tab: tab)
}
}
}
extension AppShortcutItem {
static func createItems(for application: UIApplication) {

View File

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

View File

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

View File

@ -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,16 +215,13 @@ 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,12 +9,12 @@
import UIKit
import Pachyderm
class EmojiLabel: UILabel, BaseEmojiLabel {
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
var emojiIdentifier: String?
var emojiRequests: [ImageCache.Request] = []
var emojiFont: UIFont { font }
var emojiTextColor: UIColor { textColor }
class EmojiLabel: UILabel {
private var emojiIdentifier: String?
private var emojiRequests: [ImageCache.Request] = []
func setEmojis(_ emojis: [Emoji], identifier: String) {
guard emojis.count > 0, let attributedText = attributedText else { return }
@ -23,9 +23,53 @@ class EmojiLabel: UILabel, BaseEmojiLabel {
emojiRequests.forEach { $0.cancel() }
emojiRequests = []
replaceEmojis(in: attributedText.string, emojis: emojis, identifier: identifier) { [weak self] (newAttributedText) in
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
guard let self = self, self.emojiIdentifier == identifier else { return }
self.attributedText = newAttributedText
let mutAttrString = NSMutableAttributedString(attributedString: attributedText)
// replaces the emojis starting from the end of the string as to not alter the indicies of preceeding emojis
for match in matches.reversed() {
let shortcode = (mutAttrString.string as NSString).substring(with: match.range(at: 1))
guard let emojiImage = emojiImages[shortcode] else {
continue
}
let attachment = NSTextAttachment(emojiImage: emojiImage, in: self.font, with: self.textColor)
let attachmentStr = NSAttributedString(attachment: attachment)
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
}
self.attributedText = mutAttrString
self.setNeedsLayout()
self.setNeedsDisplay()
}

View File

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

View File

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

View File

@ -19,7 +19,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
@IBOutlet weak var verticalStackView: UIStackView!
@IBOutlet weak var actionAvatarStackView: UIStackView!
@IBOutlet weak var timestampLabel: UILabel!
@IBOutlet weak var actionLabel: MultiSourceEmojiLabel!
@IBOutlet weak var actionLabel: UILabel!
@IBOutlet weak var statusContentLabel: UILabel!
var group: NotificationGroup!
@ -35,12 +35,14 @@ 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)
}
@ -98,7 +100,8 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
NSLayoutConstraint.activate(imageViews.map { $0.widthAnchor.constraint(equalTo: $0.heightAnchor) })
updateTimestamp()
actionLabel.setEmojis(pairs: people.map { ($0.displayOrUserName, $0.emojis) }, identifier: group.id)
updateActionLabel(people: people)
let doc = try! SwiftSoup.parse(status.content)
statusContentLabel.text = try! doc.text()
@ -132,7 +135,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
}
}
func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString {
func updateActionLabel(people: [AccountMO]) {
let verb: String
switch group.kind {
case .favourite:
@ -142,27 +145,18 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
default:
fatalError()
}
let peopleStr: String
// todo: figure out how to localize this
let str = NSMutableAttributedString(string: "\(verb) by ")
switch names.count {
// todo: update to use managed objects
switch people.count {
case 1:
str.append(names.first!)
peopleStr = people.first!.displayName
case 2:
str.append(names.first!)
str.append(NSAttributedString(string: " and "))
str.append(names.last!)
peopleStr = people.first!.displayName + " and " + people.last!.displayName
default:
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 "))
peopleStr = people.dropLast().map { $0.displayName }.joined(separator: ", ") + ", and " + people.last!.displayName
}
}
}
return str
actionLabel.text = "\(verb) by \(peopleStr)"
}
override func prepareForReuse() {

View File

@ -1,11 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<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">
<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">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17504.1"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16086"/>
<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>
@ -31,17 +29,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.5" height="30"/>
<rect key="frame" x="197.5" y="0.0" width="0.0" 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="206" y="0.0" width="24" height="30"/>
<rect key="frame" x="205.5" y="0.0" width="24.5" height="30"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</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" customClass="MultiSourceEmojiLabel" customModule="Tusker" customModuleProvider="target">
<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">
<rect key="frame" x="0.0" y="34" width="230" height="41"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
@ -50,7 +48,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"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
@ -85,9 +83,4 @@
<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>

View File

@ -16,7 +16,7 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
@IBOutlet weak var avatarStackView: UIStackView!
@IBOutlet weak var timestampLabel: UILabel!
@IBOutlet weak var actionLabel: MultiSourceEmojiLabel!
@IBOutlet weak var actionLabel: UILabel!
var group: NotificationGroup!
@ -30,12 +30,13 @@ 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)
}
@ -46,9 +47,7 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) }
actionLabel.setEmojis(pairs: people.map {
($0.displayOrUserName, $0.emojis)
}, identifier: group.id)
updateActionLabel(people: people)
updateTimestamp()
avatarStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
@ -72,27 +71,20 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
}
}
func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString {
func updateActionLabel(people: [AccountMO]) {
// todo: custom emoji in people display names
// todo: figure out how to localize this
let str = NSMutableAttributedString(string: "Followed by ")
switch names.count {
let peopleStr: String
switch people.count {
case 1:
str.append(names.first!)
peopleStr = people.first!.displayOrUserName
case 2:
str.append(names.first!)
str.append(NSAttributedString(string: " and "))
str.append(names.last!)
peopleStr = people.first!.displayOrUserName + " and " + people.last!.displayOrUserName
default:
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 "))
peopleStr = people.dropLast().map { $0.displayOrUserName }.joined(separator: ", ") + ", and " + people.last!.displayOrUserName
}
}
}
return str
actionLabel.text = "Followed by \(peopleStr)"
}
func updateTimestamp() {

View File

@ -1,11 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<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">
<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">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17504.1"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14819.2"/>
<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>
@ -31,17 +29,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.5" height="30"/>
<rect key="frame" x="205.5" y="0.0" width="0.0" 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="206" y="0.0" width="24" height="30"/>
<rect key="frame" x="205.5" y="0.0" width="24.5" height="30"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</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" customClass="MultiSourceEmojiLabel" customModule="Tusker" customModuleProvider="target">
<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">
<rect key="frame" x="0.0" y="30" width="230" height="46"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
@ -50,7 +48,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="34" y="12.5" width="32" height="30"/>
<rect key="frame" x="36" y="12.5" width="30" height="30.5"/>
<constraints>
<constraint firstAttribute="width" constant="30" id="gvV-4g-2Xr"/>
<constraint firstAttribute="height" constant="30" id="lS8-fq-ptY"/>
@ -76,9 +74,6 @@
</tableViewCell>
</objects>
<resources>
<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>
<image name="person.badge.plus.fill" catalog="system" width="64" height="58"/>
</resources>
</document>

View File

@ -23,11 +23,7 @@ class ProfileHeaderView: UIView {
return nib.instantiate(withOwner: nil, options: nil).first as! ProfileHeaderView
}
weak var delegate: ProfileHeaderViewDelegate? {
didSet {
createObservers()
}
}
weak var delegate: ProfileHeaderViewDelegate?
var mastodonController: MastodonController! { delegate?.apiController }
@IBOutlet weak var headerImageView: UIImageView!
@ -45,10 +41,10 @@ class ProfileHeaderView: UIView {
var accountID: String!
private var avatarRequest: ImageCache.Request?
private var headerRequest: ImageCache.Request?
var avatarRequest: ImageCache.Request?
var headerRequest: ImageCache.Request?
private var cancellables = [AnyCancellable]()
private var accountUpdater: Cancellable?
deinit {
avatarRequest?.cancel()
@ -71,27 +67,15 @@ 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
if #available(iOS 14.0, *) {
moreButton.showsMenuAsPrimaryAction = true
moreButton.isContextMenuInteractionEnabled = true
}
}
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)
#endif
}
func updateUI(for accountID: String) {
@ -131,9 +115,6 @@ 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,
@ -141,7 +122,9 @@ class ProfileHeaderView: UIView {
let relationship = results.first else {
return
}
self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
DispatchQueue.main.async {
self.followsYouLabel.isHidden = !relationship.followedBy
}
}
}
@ -173,14 +156,13 @@ class ProfileHeaderView: UIView {
nameLabel.heightAnchor.constraint(equalTo: valueTextView.heightAnchor).isActive = true
}
}
private func updateRelationship() {
guard let relationship = mastodonController.persistentContainer.relationship(forAccount: accountID) else {
return
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) }
}
followsYouLabel.isHidden = !relationship.followedBy
}
@objc private func updateUIForPreferences() {

View File

@ -9,7 +9,6 @@
import UIKit
import Pachyderm
import Combine
import AVKit
protocol StatusTableViewCellDelegate: TuskerNavigationDelegate {
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell)
@ -71,8 +70,6 @@ class BaseStatusTableViewCell: UITableViewCell {
private var statusUpdater: Cancellable?
private var accountUpdater: Cancellable?
private var currentPictureInPictureVideoStatusID: String?
override func awakeFromNib() {
super.awakeFromNib()
@ -90,9 +87,11 @@ 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)
}
@ -123,25 +122,19 @@ class BaseStatusTableViewCell: UITableViewCell {
}
}
final func updateUI(statusID: String, state: StatusState) {
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, status: status)
updateUIForPreferences(account: account)
attachmentsView.updateUI(status: status)
attachmentsView.isAccessibilityElement = status.attachments.count > 0
@ -201,10 +194,12 @@ 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) {
@ -218,19 +213,18 @@ class BaseStatusTableViewCell: UITableViewCell {
}
}
@objc private func preferencesChanged() {
@objc 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, status: status)
updateUIForPreferences(account: account)
updateStatusIconsForPreferences(status)
}
func updateUIForPreferences(account: AccountMO, status: StatusMO) {
func updateUIForPreferences(account: AccountMO) {
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) {
@ -363,74 +357,15 @@ class BaseStatusTableViewCell: UITableViewCell {
}
extension BaseStatusTableViewCell: AttachmentViewDelegate {
func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? {
func attachmentViewGallery(startingAt index: Int) -> UIViewController? {
guard let delegate = delegate,
let status = mastodonController.persistentContainer.status(for: statusID) else { return nil }
let sourceViews = status.attachments.map(attachmentsView.getAttachmentView(for:))
let gallery = delegate.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index)
gallery.avPlayerViewControllerDelegate = self
return gallery
return delegate.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index)
}
func attachmentViewPresent(_ vc: UIViewController, animated: Bool) {
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)
}
delegate?.show(vc)
}
}

View File

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

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<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">
<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">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17504.1"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17124"/>
<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="0.0"/>
<rect key="frame" x="0.0" y="176" width="343" height="193"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
<constraints>
<constraint firstAttribute="height" secondItem="IF9-9U-Gk0" secondAttribute="width" multiplier="9:16" priority="999" id="5oh-eK-J5d"/>
<constraint firstAttribute="width" secondItem="IF9-9U-Gk0" secondAttribute="height" multiplier="16:9" id="5oh-eK-J5d"/>
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ejU-sO-Og5">

View File

@ -68,9 +68,10 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
}
}
override func doUpdateUI(status: StatusMO, state: StatusState) {
var status = status
override func updateUI(statusID: String, state: StatusState) {
guard var status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID)") }
let realStatusID: String
if let rebloggedStatus = status.reblog {
reblogStatusID = statusID
rebloggerID = status.account.id
@ -78,24 +79,25 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
updateRebloggerLabel(reblogger: status.account)
status = rebloggedStatus
statusID = rebloggedStatus.id
realStatusID = rebloggedStatus.id
} else {
reblogStatusID = nil
rebloggerID = nil
reblogLabel.isHidden = true
realStatusID = statusID
}
super.doUpdateUI(status: status, state: state)
super.updateUI(statusID: realStatusID, state: state)
doUpdateTimestamp(status: status)
updateTimestamp()
let pinned = showPinned && (status.pinned ?? false)
timestampLabel.isHidden = pinned
pinImageView.isHidden = !pinned
}
override func updateUIForPreferences(account: AccountMO, status: StatusMO) {
super.updateUIForPreferences(account: account, status: status)
@objc override func preferencesChanged() {
super.preferencesChanged()
if let rebloggerID = rebloggerID,
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
@ -119,16 +121,12 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
replyImageView.isHidden = !Preferences.shared.showIsStatusReplyIcon || !showReplyIndicator || status.inReplyToID == nil
}
private func updateTimestamp() {
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())

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<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">
<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">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17504.1"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17124"/>
<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,14 +115,55 @@
<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="0.0"/>
<rect key="frame" x="0.0" y="169.5" width="277" height="156"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
<constraints>
<constraint firstAttribute="height" secondItem="nbq-yr-2mA" secondAttribute="width" multiplier="9:16" priority="999" id="Rvt-zs-fkd"/>
<constraint firstAttribute="width" secondItem="nbq-yr-2mA" secondAttribute="height" multiplier="16:9" 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>
@ -145,78 +186,20 @@
</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 firstItem="TUP-Nz-5Yh" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="oie-wK-IpU" secondAttribute="bottom" id="7Xp-Sa-Rfk"/>
<constraint firstAttribute="bottom" secondItem="Zlb-yt-NTw" secondAttribute="bottom" id="HOe-6l-ES0"/>
<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="TUP-Nz-5Yh" firstAttribute="leading" secondItem="ve3-Y1-NQH" secondAttribute="leading" id="QZ2-iO-ckC"/>
<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="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>

View File

@ -52,7 +52,9 @@ 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)))
}
@ -61,10 +63,12 @@ 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
}

View File

@ -16,16 +16,12 @@ fileprivate class WeakWrapper<T: AnyObject> {
}
}
struct WeakArray<Element: AnyObject>: MutableCollection, RangeReplaceableCollection {
struct WeakArray<Element: AnyObject>: Collection {
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) }
}
@ -34,20 +30,11 @@ struct WeakArray<Element: AnyObject>: MutableCollection, RangeReplaceableCollect
array = elements.map { WeakWrapper($0) }
}
subscript(position: Int) -> Element? {
get {
array[position].value
}
set(newValue) {
array[position] = WeakWrapper(newValue)
}
subscript(_ index: Int) -> Element? {
return array[index].value
}
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) })
}
}

View File

@ -1,25 +0,0 @@
//
// 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)
}
}