Compare commits

...

25 Commits

Author SHA1 Message Date
Shadowfacts 123a512d3c Bump build number and update changelog 2022-09-18 22:14:54 -04:00
Shadowfacts d141ed7d03 Enable reblog with visibility on Pleroma 2022-09-18 22:01:57 -04:00
Shadowfacts 95e120afd6 Fix large image controls not being hidden on iPhone 14 Pro 2022-09-18 11:30:50 -04:00
Shadowfacts ca8a214cf6 Add reblog with visibility menu to reblog confirmation alert 2022-09-18 11:28:33 -04:00
Shadowfacts 7161861d36 Add API param for reblog visibility 2022-09-18 11:28:33 -04:00
Shadowfacts c6c8f63e39 Fix compose reply view not working after ContentTextView refactor, use named CoordinateSpace for calculating scroll offset in reply avatar view 2022-09-18 11:28:33 -04:00
Shadowfacts e9962997a6 Show preview of status in reblog confirmation alert
Closes #121
2022-09-17 20:27:36 -04:00
Shadowfacts f2ab1778c5 Replace expanded emoji picker with SwiftUI 2022-09-15 21:49:50 -04:00
Shadowfacts 0f71d61b88 Fix crash when there are duplicate emojis
Closes #164
2022-09-15 21:10:52 -04:00
Shadowfacts 80c4fcce82 Use AnyAccount instead of EitherAccount for compose autocomplete 2022-09-15 21:05:18 -04:00
Shadowfacts 8f8d50efbd Bring back StatusProtocol 2022-09-15 21:04:53 -04:00
Shadowfacts 43b4976ed7 Maybe fix timeline discontinuities
See #174
2022-09-15 20:54:28 -04:00
Shadowfacts ff3681627b Fix reblog status cell not showing selection background in spacer
Closes #175
2022-09-15 20:45:45 -04:00
Shadowfacts 35d21fb725 Switch to stable, hash-based account IDs
#160
2022-09-12 23:05:35 -04:00
Shadowfacts bbfb3b0a7a Add loading indicator to DiffableTimelineLikeTableViewController 2022-09-12 22:05:19 -04:00
Shadowfacts 8b78a5e7ad Don't parent background managed object contexts to view context
Otherwise, certain operations require the background contexts to
interact with the view context, which can block the main thread from
accessing the view context (potentially causing hitches if the view
context access is in a critical path, like cell fetching).
2022-09-11 23:00:51 -04:00
Shadowfacts 66c17006d1 Fix poll votes displaying random number
i have no idea where the number was coming from
2022-09-11 22:35:09 -04:00
Shadowfacts 8a911f238b Fix emojis getting set without setting emoji identifier 2022-09-11 22:20:46 -04:00
Shadowfacts 77c44c323f Use os_unfair_lock for MultiThreadDictionary instead of DispatchQueue 2022-09-11 22:20:46 -04:00
Shadowfacts c2d1fe45d8 Update for iPhone 14 series 2022-09-07 18:43:46 -04:00
Shadowfacts 24591cee05 Improve account switching animation 2022-08-01 21:29:24 -04:00
Shadowfacts 50dd785ef8 ContentTextView cleanup 2022-07-31 19:39:14 -04:00
Shadowfacts af2e95ea39 Fix apparent crash when tapping tab bar item of selected tab 2022-07-11 15:07:11 -04:00
Shadowfacts 4fa1bd7268 Fix crash due to nested navigation controllers 2022-07-11 14:59:01 -04:00
Shadowfacts ea07e6aef6 Simplify timeline status cell layout, fix due to missing constraint
Fixes crash when re-showing timeline actions after being hidden
2022-07-11 14:42:49 -04:00
44 changed files with 1369 additions and 654 deletions

View File

@ -1,5 +1,18 @@
# Changelog # Changelog
## 2022.1 (35)
Features/Improvements:
- Add loading indicator to timelines/notifications/profiles
- Show status preview in reblog confirmation dialog (Preferences -> Behavior -> Require confirmation Before Reblogging)
- Add reblogging with unlisted/private visibility (requires reblog confirmation to be enabled)
- Fix controls not hiding on iPhone 14 Pro
- Improve account switching animation
Bugfixes:
- Fix crash when resizing window on iPad
- Fix poll vote count displaying random number
- Fix crash when opening emoji picker on instances that have duplicate emojis
## 2022.1 (33) ## 2022.1 (33)
Features/Improvements: Features/Improvements:
- Show notifications when subscribed to other people's posts - Show notifications when subscribed to other people's posts

View File

@ -20,8 +20,9 @@ public protocol StatusProtocol {
var createdAt: Date { get } var createdAt: Date { get }
var reblogsCount: Int { get } var reblogsCount: Int { get }
var favouritesCount: Int { get } var favouritesCount: Int { get }
var reblogged: Bool { get } // pachyderm impl wants Bool, StatusMO wants optional. not sure how to resolve it, but we don't need this currently
var favourited: Bool { get } // var reblogged: Bool { get }
// var favourited: Bool { get }
var sensitive: Bool { get } var sensitive: Bool { get }
var spoilerText: String { get } var spoilerText: String { get }
var visibility: Pachyderm.Status.Visibility { get } var visibility: Pachyderm.Status.Visibility { get }

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
public final class Status: /*StatusProtocol,*/ Decodable { public final class Status: StatusProtocol, Decodable {
public let id: String public let id: String
public let uri: String public let uri: String
public let url: URL? public let url: URL?
@ -67,8 +67,13 @@ public final class Status: /*StatusProtocol,*/ Decodable {
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(status.id)") return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(status.id)")
} }
public static func reblog(_ statusID: String) -> Request<Status> { public static func reblog(_ statusID: String, visibility: Visibility? = nil) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/reblog") var params: [Parameter] = []
if let visibility {
assert([.public, .unlisted, .private].contains(visibility))
params.append("visibility" => visibility.rawValue)
}
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/reblog", queryParameters: params)
} }
public static func unreblog(_ statusID: String) -> Request<Status> { public static func unreblog(_ statusID: String) -> Request<Status> {

View File

@ -50,6 +50,7 @@
D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A5402635FB3C0095BD04 /* PollOptionView.swift */; }; D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A5402635FB3C0095BD04 /* PollOptionView.swift */; };
D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A542263634100095BD04 /* PollOptionCheckboxView.swift */; }; D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A542263634100095BD04 /* PollOptionCheckboxView.swift */; };
D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */; }; D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */; };
D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */; };
D626493523BD94CE00612E6E /* CompositionAttachmentData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */; }; D626493523BD94CE00612E6E /* CompositionAttachmentData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */; };
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626493623C0FD0000612E6E /* AllPhotosTableViewCell.swift */; }; D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626493623C0FD0000612E6E /* AllPhotosTableViewCell.swift */; };
D626493923C0FD0000612E6E /* AllPhotosTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D626493723C0FD0000612E6E /* AllPhotosTableViewCell.xib */; }; D626493923C0FD0000612E6E /* AllPhotosTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D626493723C0FD0000612E6E /* AllPhotosTableViewCell.xib */; };
@ -121,6 +122,7 @@
D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */; }; D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */; };
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; }; D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; };
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */; }; D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */; };
D6552367289870790048A653 /* ScreenCorners in Frameworks */ = {isa = PBXBuildFile; productRef = D6552366289870790048A653 /* ScreenCorners */; };
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */; }; D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */; };
D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; }; D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; };
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; }; D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; };
@ -140,7 +142,6 @@
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */; }; D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */; };
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */; }; D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */; };
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; }; D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; };
D670F8B62537DC890046588A /* EmojiPickerWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D670F8B52537DC890046588A /* EmojiPickerWrapper.swift */; };
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; }; D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; };
D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7B2157E01900721E32 /* XCBManager.swift */; }; D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7B2157E01900721E32 /* XCBManager.swift */; };
D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */; }; D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */; };
@ -165,6 +166,8 @@
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */; }; D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */; };
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */; }; D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */; };
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */; }; D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */; };
D6895DC228D65274006341DA /* CustomAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC128D65274006341DA /* CustomAlertController.swift */; };
D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */; };
D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */; }; D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */; };
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */; }; D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */; };
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; }; D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; };
@ -244,8 +247,6 @@
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */; }; D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */; };
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; }; D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; };
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; }; D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; };
D6C143E025354E34007DC240 /* EmojiPickerCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143DF25354E34007DC240 /* EmojiPickerCollectionViewController.swift */; };
D6C143FD25354FD0007DC240 /* EmojiCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143FB25354FD0007DC240 /* EmojiCollectionViewCell.swift */; };
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; }; D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; };
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; }; D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; };
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; }; D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; };
@ -396,6 +397,7 @@
D623A5402635FB3C0095BD04 /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = "<group>"; }; D623A5402635FB3C0095BD04 /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = "<group>"; };
D623A542263634100095BD04 /* PollOptionCheckboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionCheckboxView.swift; sourceTree = "<group>"; }; D623A542263634100095BD04 /* PollOptionCheckboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionCheckboxView.swift; sourceTree = "<group>"; };
D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableTableViewCell.swift; sourceTree = "<group>"; }; D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableTableViewCell.swift; sourceTree = "<group>"; };
D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingTableViewCell.swift; sourceTree = "<group>"; };
D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachmentData.swift; sourceTree = "<group>"; }; D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachmentData.swift; sourceTree = "<group>"; };
D626493623C0FD0000612E6E /* AllPhotosTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllPhotosTableViewCell.swift; sourceTree = "<group>"; }; D626493623C0FD0000612E6E /* AllPhotosTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllPhotosTableViewCell.swift; sourceTree = "<group>"; };
D626493723C0FD0000612E6E /* AllPhotosTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AllPhotosTableViewCell.xib; sourceTree = "<group>"; }; D626493723C0FD0000612E6E /* AllPhotosTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AllPhotosTableViewCell.xib; sourceTree = "<group>"; };
@ -489,7 +491,6 @@
D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTableViewController.swift; sourceTree = "<group>"; }; D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTableViewController.swift; sourceTree = "<group>"; };
D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Equatable.swift"; sourceTree = "<group>"; }; D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Equatable.swift"; sourceTree = "<group>"; };
D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; }; D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; };
D670F8B52537DC890046588A /* EmojiPickerWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerWrapper.swift; sourceTree = "<group>"; };
D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Pachyderm; sourceTree = "<group>"; }; D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Pachyderm; sourceTree = "<group>"; };
D6757A7B2157E01900721E32 /* XCBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBManager.swift; sourceTree = "<group>"; }; D6757A7B2157E01900721E32 /* XCBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBManager.swift; sourceTree = "<group>"; };
D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBRequestSpec.swift; sourceTree = "<group>"; }; D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBRequestSpec.swift; sourceTree = "<group>"; };
@ -514,6 +515,8 @@
D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountActivityItemSource.swift; sourceTree = "<group>"; }; D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountActivityItemSource.swift; sourceTree = "<group>"; };
D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeDrawingViewController.swift; sourceTree = "<group>"; }; D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeDrawingViewController.swift; sourceTree = "<group>"; };
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedProgressView.swift; sourceTree = "<group>"; }; D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedProgressView.swift; sourceTree = "<group>"; };
D6895DC128D65274006341DA /* CustomAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAlertController.swift; sourceTree = "<group>"; };
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmReblogStatusPreviewView.swift; sourceTree = "<group>"; };
D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerControlCollectionViewCell.swift; sourceTree = "<group>"; }; D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerControlCollectionViewCell.swift; sourceTree = "<group>"; };
D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuxiliarySceneDelegate.swift; sourceTree = "<group>"; }; D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuxiliarySceneDelegate.swift; sourceTree = "<group>"; };
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = "<group>"; }; D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = "<group>"; };
@ -590,8 +593,6 @@
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = "<group>"; }; D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = "<group>"; };
D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; }; D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; };
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = "<group>"; }; D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.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>"; };
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; }; D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; };
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = "<group>"; }; D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = "<group>"; };
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; }; D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; };
@ -668,6 +669,7 @@
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */, D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */,
D69CCBBF249E6EFD000AF167 /* CrashReporter in Frameworks */, D69CCBBF249E6EFD000AF167 /* CrashReporter in Frameworks */,
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */, D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */,
D6552367289870790048A653 /* ScreenCorners in Frameworks */,
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */, D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -973,9 +975,6 @@
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */, D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */,
D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */, D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */,
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */, D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */,
D6C143DF25354E34007DC240 /* EmojiPickerCollectionViewController.swift */,
D6C143FB25354FD0007DC240 /* EmojiCollectionViewCell.swift */,
D670F8B52537DC890046588A /* EmojiPickerWrapper.swift */,
); );
path = Compose; path = Compose;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1258,6 +1257,7 @@
children = ( children = (
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */, D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */, D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */,
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
D620483523D38075008A63EF /* ContentTextView.swift */, D620483523D38075008A63EF /* ContentTextView.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */, D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
D6969E9F240C8384002843CE /* EmojiLabel.swift */, D6969E9F240C8384002843CE /* EmojiLabel.swift */,
@ -1266,6 +1266,7 @@
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */, D6DD2A44273D6C5700386A6C /* GIFImageView.swift */,
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */, D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */,
D620483323D3801D008A63EF /* LinkTextView.swift */, D620483323D3801D008A63EF /* LinkTextView.swift */,
D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */,
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */, D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */, D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */, D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
@ -1297,6 +1298,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */, D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */,
D6895DC128D65274006341DA /* CustomAlertController.swift */,
D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */, D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */,
D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */, D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */,
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */, D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */,
@ -1506,6 +1508,7 @@
D60CFFDA24A290BA00D00083 /* SwiftSoup */, D60CFFDA24A290BA00D00083 /* SwiftSoup */,
D6676CA427A8D0020052936B /* WebURLFoundationExtras */, D6676CA427A8D0020052936B /* WebURLFoundationExtras */,
D674A50827F9128D00BA03AC /* Pachyderm */, D674A50827F9128D00BA03AC /* Pachyderm */,
D6552366289870790048A653 /* ScreenCorners */,
); );
productName = Tusker; productName = Tusker;
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */; productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
@ -1614,6 +1617,7 @@
D69CCBBD249E6EFD000AF167 /* XCRemoteSwiftPackageReference "plcrashreporter" */, D69CCBBD249E6EFD000AF167 /* XCRemoteSwiftPackageReference "plcrashreporter" */,
D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */, D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */, D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */,
D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */,
); );
productRefGroup = D6D4DDCD212518A000E1C4BB /* Products */; productRefGroup = D6D4DDCD212518A000E1C4BB /* Products */;
projectDirPath = ""; projectDirPath = "";
@ -1772,6 +1776,7 @@
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */, D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */,
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */, D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */,
D6B22A0F2560D52D004D82EF /* TabbedPageViewController.swift in Sources */, D6B22A0F2560D52D004D82EF /* TabbedPageViewController.swift in Sources */,
D6895DC228D65274006341DA /* CustomAlertController.swift in Sources */,
D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */, D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */,
0411610022B442870030A9B7 /* LoadingLargeImageViewController.swift in Sources */, 0411610022B442870030A9B7 /* LoadingLargeImageViewController.swift in Sources */,
D6A4DCE525537C7A00D9DE31 /* FastSwitchingAccountView.swift in Sources */, D6A4DCE525537C7A00D9DE31 /* FastSwitchingAccountView.swift in Sources */,
@ -1797,7 +1802,6 @@
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */, D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */, D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */, D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */,
D6C143FD25354FD0007DC240 /* EmojiCollectionViewCell.swift in Sources */,
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */, D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */, D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */,
D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */, D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */,
@ -1819,7 +1823,6 @@
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */, D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */,
D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */, D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */,
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */, D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
D6C143E025354E34007DC240 /* EmojiPickerCollectionViewController.swift in Sources */,
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */, D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */,
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */, D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */, D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
@ -1914,6 +1917,7 @@
D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */, D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */,
D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */, D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */,
D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */, D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */,
D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */,
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */, D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */,
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */, D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */, D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,
@ -1925,7 +1929,6 @@
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */, D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */,
D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */, D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */,
D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */, D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */,
D670F8B62537DC890046588A /* EmojiPickerWrapper.swift in Sources */,
D6B81F442560390300F6E31D /* MenuController.swift in Sources */, D6B81F442560390300F6E31D /* MenuController.swift in Sources */,
D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */, D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */,
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */, D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */,
@ -1947,6 +1950,7 @@
D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */, D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */,
D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */, D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */,
D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */, D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */,
D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */,
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */, D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */,
D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */, D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */,
04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */, 04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */,
@ -2198,7 +2202,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 33; CURRENT_PROJECT_VERSION = 35;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2228,7 +2232,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 33; CURRENT_PROJECT_VERSION = 35;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2337,7 +2341,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 33; CURRENT_PROJECT_VERSION = 35;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2364,7 +2368,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 33; CURRENT_PROJECT_VERSION = 35;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2443,6 +2447,14 @@
minimumVersion = 2.3.2; minimumVersion = 2.3.2;
}; };
}; };
D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/kylebshr/ScreenCorners";
requirement = {
kind = upToNextMinorVersion;
minimumVersion = 1.0.1;
};
};
D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */ = { D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/karwa/swift-url"; repositoryURL = "https://github.com/karwa/swift-url";
@ -2467,6 +2479,11 @@
package = D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */; package = D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
productName = SwiftSoup; productName = SwiftSoup;
}; };
D6552366289870790048A653 /* ScreenCorners */ = {
isa = XCSwiftPackageProductDependency;
package = D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */;
productName = ScreenCorners;
};
D6676CA427A8D0020052936B /* WebURLFoundationExtras */ = { D6676CA427A8D0020052936B /* WebURLFoundationExtras */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */; package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */;

View File

@ -24,7 +24,7 @@ class ImageCache {
private let cache: ImageDataCache private let cache: ImageDataCache
private let desiredPixelSize: CGSize? private let desiredPixelSize: CGSize?
private var groups = MultiThreadDictionary<URL, RequestGroup>(name: "ImageCache request groups") private var groups = MultiThreadDictionary<URL, RequestGroup>()
private var backgroundQueue = DispatchQueue(label: "ImageCache completion queue", qos: .default) private var backgroundQueue = DispatchQueue(label: "ImageCache completion queue", qos: .default)
@ -73,6 +73,7 @@ class ImageCache {
} }
func get(_ url: URL, loadOriginal: Bool = false) async -> (Data?, UIImage?) { func get(_ url: URL, loadOriginal: Bool = false) async -> (Data?, UIImage?) {
// todo: this should integrate with the task cancellation mechanism somehow
return await withCheckedContinuation { continuation in return await withCheckedContinuation { continuation in
_ = get(url, loadOriginal: loadOriginal) { data, image in _ = get(url, loadOriginal: loadOriginal) { data, image in
continuation.resume(returning: (data, image)) continuation.resume(returning: (data, image))
@ -96,7 +97,7 @@ class ImageCache {
if let data = data { if let data = data {
try? self.cache.set(url.absoluteString, data: data, image: image) try? self.cache.set(url.absoluteString, data: data, image: image)
} }
self.groups.removeValueWithoutReturning(forKey: url) _ = self.groups.removeValue(forKey: url)
} }
groups[url] = group groups[url] = group
return group return group

View File

@ -20,13 +20,15 @@ class MastodonCachePersistentStore: NSPersistentContainer {
private(set) lazy var backgroundContext: NSManagedObjectContext = { private(set) lazy var backgroundContext: NSManagedObjectContext = {
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
context.parent = self.viewContext context.persistentStoreCoordinator = self.persistentStoreCoordinator
context.automaticallyMergesChangesFromParent = true
return context return context
}() }()
private(set) lazy var prefetchBackgroundContext: NSManagedObjectContext = { private(set) lazy var prefetchBackgroundContext: NSManagedObjectContext = {
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
context.parent = self.viewContext context.persistentStoreCoordinator = self.persistentStoreCoordinator
context.automaticallyMergesChangesFromParent = true
return context return context
}() }()
@ -51,6 +53,8 @@ class MastodonCachePersistentStore: NSPersistentContainer {
} }
} }
viewContext.automaticallyMergesChangesFromParent = true
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext) NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext)
} }

View File

@ -10,8 +10,11 @@ import Foundation
import Pachyderm import Pachyderm
struct InstanceFeatures { struct InstanceFeatures {
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; Pleroma (.*)\\)")
private(set) var instanceType = InstanceType.mastodon private(set) var instanceType = InstanceType.mastodon
private(set) var version: Version? private(set) var version: Version?
private(set) var pleromaVersion: Version?
private(set) var maxStatusChars = 500 private(set) var maxStatusChars = 500
var localOnlyPosts: Bool { var localOnlyPosts: Bool {
@ -39,7 +42,12 @@ struct InstanceFeatures {
} }
var trendingStatusesAndLinks: Bool { var trendingStatusesAndLinks: Bool {
instanceType == .mastodon && version != nil && version! >= Version(3, 5, 0) instanceType == .mastodon && hasVersion(3, 5, 0)
}
var reblogVisibility: Bool {
(instanceType == .mastodon && hasVersion(2, 8, 0))
|| (instanceType == .pleroma && hasVersion(2, 0, 0))
} }
mutating func update(instance: Instance, nodeInfo: NodeInfo?) { mutating func update(instance: Instance, nodeInfo: NodeInfo?) {
@ -58,8 +66,20 @@ struct InstanceFeatures {
version = Version(string: ver) version = Version(string: ver)
if let match = InstanceFeatures.pleromaVersionRegex.firstMatch(in: ver, range: NSRange(location: 0, length: ver.utf16.count)) {
pleromaVersion = Version(string: (ver as NSString).substring(with: match.range(at: 1)))
}
maxStatusChars = instance.maxStatusCharacters ?? 500 maxStatusChars = instance.maxStatusCharacters ?? 500
} }
func hasVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
if let version {
return version >= Version(major, minor, patch)
} else {
return false
}
}
} }
extension InstanceFeatures { extension InstanceFeatures {
@ -83,6 +103,8 @@ extension InstanceFeatures {
extension InstanceFeatures { extension InstanceFeatures {
struct Version: Equatable, Comparable { struct Version: Equatable, Comparable {
private static let regex = try! NSRegularExpression(pattern: "^(\\d+)\\.(\\d+)\\.(\\d+).*$")
let major: Int let major: Int
let minor: Int let minor: Int
let patch: Int let patch: Int
@ -94,8 +116,7 @@ extension InstanceFeatures {
} }
init?(string: String) { init?(string: String) {
let regex = try! NSRegularExpression(pattern: "^(\\d+)\\.(\\d+)\\.(\\d+).*$") guard let match = Version.regex.firstMatch(in: string, range: NSRange(location: 0, length: string.utf16.count)),
guard let match = regex.firstMatch(in: string, range: NSRange(location: 0, length: string.utf16.count)),
match.numberOfRanges == 4 else { match.numberOfRanges == 4 else {
return nil return nil
} }

View File

@ -8,6 +8,7 @@
import Foundation import Foundation
import Combine import Combine
import CryptoKit
class LocalData: ObservableObject { class LocalData: ObservableObject {
@ -22,7 +23,6 @@ class LocalData: ObservableObject {
if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING_LOGIN") { if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING_LOGIN") {
accounts = [ accounts = [
UserAccountInfo( UserAccountInfo(
id: UUID().uuidString,
instanceURL: URL(string: "http://localhost:8080")!, instanceURL: URL(string: "http://localhost:8080")!,
clientID: "client_id", clientID: "client_id",
clientSecret: "client_secret", clientSecret: "client_secret",
@ -33,23 +33,15 @@ class LocalData: ObservableObject {
} else { } else {
defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")! defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")!
} }
migrateAccountIDsIfNecessary()
} }
private let accountsKey = "accounts" private let accountsKey = "accounts"
var accounts: [UserAccountInfo] { private(set) var accounts: [UserAccountInfo] {
get { get {
if let array = defaults.array(forKey: accountsKey) as? [[String: String]] { if let array = defaults.array(forKey: accountsKey) as? [[String: String]] {
return array.compactMap { (info) in return array.compactMap(UserAccountInfo.init(userDefaultsDict:))
guard let id = info["id"],
let instanceURL = info["instanceURL"],
let url = URL(string: instanceURL),
let clientId = info["clientID"],
let secret = info["clientSecret"],
let accessToken = info["accessToken"] else {
return nil
}
return UserAccountInfo(id: id, instanceURL: url, clientID: clientId, clientSecret: secret, username: info["username"], accessToken: accessToken)
}
} else { } else {
return [] return []
} }
@ -84,6 +76,41 @@ class LocalData: ObservableObject {
} }
} }
private let usesAccountIDHashesKey = "usesAccountIDHashes"
private var usesAccountIDHashes: Bool {
get {
return defaults.bool(forKey: usesAccountIDHashesKey)
}
set {
return defaults.set(newValue, forKey: usesAccountIDHashesKey)
}
}
private func migrateAccountIDsIfNecessary() {
if usesAccountIDHashes {
return
}
if let mostRecentAccount = getMostRecentAccount() {
let hashedMostRecentID = UserAccountInfo.id(instanceURL: mostRecentAccount.instanceURL, username: mostRecentAccount.username)
mostRecentAccountID = hashedMostRecentID
}
if let array = defaults.array(forKey: accountsKey) as? [[String: String]] {
accounts = array.compactMap {
guard let urlString = $0["instanceURL"],
let url = URL(string: urlString),
let username = $0["username"] else {
return nil
}
var dict = $0
dict["id"] = UserAccountInfo.id(instanceURL: url, username: username)
return UserAccountInfo(userDefaultsDict: dict)
}
}
usesAccountIDHashes = true
}
// MARK: - Account Management
var onboardingComplete: Bool { var onboardingComplete: Bool {
return !accounts.isEmpty return !accounts.isEmpty
} }
@ -93,20 +120,12 @@ class LocalData: ObservableObject {
if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) { if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) {
accounts.remove(at: index) accounts.remove(at: index)
} }
let id = UUID().uuidString let info = UserAccountInfo(instanceURL: url, clientID: clientID, clientSecret: secret, username: username, accessToken: accessToken)
let info = UserAccountInfo(id: id, instanceURL: url, clientID: clientID, clientSecret: secret, username: username, accessToken: accessToken)
accounts.append(info) accounts.append(info)
self.accounts = accounts self.accounts = accounts
return info return info
} }
func setUsername(for info: UserAccountInfo, username: String) {
var info = info
info.username = username
removeAccount(info)
accounts.append(info)
}
func removeAccount(_ info: UserAccountInfo) { func removeAccount(_ info: UserAccountInfo) {
accounts.removeAll(where: { $0.id == info.id }) accounts.removeAll(where: { $0.id == info.id })
} }
@ -138,9 +157,57 @@ extension LocalData {
let instanceURL: URL let instanceURL: URL
let clientID: String let clientID: String
let clientSecret: String let clientSecret: String
fileprivate(set) var username: String! private(set) var username: String!
let accessToken: String let accessToken: String
fileprivate static let tempAccountID = "temp"
fileprivate static func id(instanceURL: URL, username: String?) -> String {
// We hash the instance host and username to form the account ID
// so that account IDs will match across devices, allowing for data syncing and handoff.
var hasher = SHA256()
hasher.update(data: instanceURL.host!.data(using: .utf8)!)
if let username {
hasher.update(data: username.data(using: .utf8)!)
}
return Data(hasher.finalize()).base64EncodedString()
}
/// Only to be used for temporary MastodonController needed to fetch own account info and create final UserAccountInfo with real username
init(tempInstanceURL instanceURL: URL, clientID: String, clientSecret: String, accessToken: String) {
self.id = UserAccountInfo.tempAccountID
self.instanceURL = instanceURL
self.clientID = clientID
self.clientSecret = clientSecret
self.accessToken = accessToken
}
fileprivate init(instanceURL: URL, clientID: String, clientSecret: String, username: String? = nil, accessToken: String) {
self.id = UserAccountInfo.id(instanceURL: instanceURL, username: username)
self.instanceURL = instanceURL
self.clientID = clientID
self.clientSecret = clientSecret
self.username = username
self.accessToken = accessToken
}
fileprivate init?(userDefaultsDict dict: [String: String]) {
guard let id = dict["id"],
let instanceURL = dict["instanceURL"],
let url = URL(string: instanceURL),
let clientID = dict["clientID"],
let secret = dict["clientSecret"],
let accessToken = dict["accessToken"] else {
return nil
}
self.id = id
self.instanceURL = url
self.clientID = clientID
self.clientSecret = secret
self.username = dict["username"]
self.accessToken = accessToken
}
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
hasher.combine(id) hasher.combine(id)
} }

View File

@ -7,52 +7,79 @@
// //
import Foundation import Foundation
import os
class MultiThreadDictionary<Key: Hashable, Value> { // once we target iOS 16, replace uses of this with OSAllocatedUnfairLock<[Key: Value]>
private let name: String // to make the lock semantics more clear
private var dict = [Key: Value]() @available(iOS, obsoleted: 16.0)
private let queue: DispatchQueue class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable> {
private let lock: any Lock<[Key: Value]>
init(name: String) { init() {
self.name = name if #available(iOS 16.0, *) {
self.queue = DispatchQueue(label: "MultiThreadDictionary (\(name)) Coordinator", attributes: .concurrent) self.lock = OSAllocatedUnfairLock(initialState: [:])
} else {
self.lock = UnfairLock(initialState: [:])
}
} }
subscript(key: Key) -> Value? { subscript(key: Key) -> Value? {
get { get {
var result: Value? = nil return lock.withLock { dict in
queue.sync { dict[key]
result = dict[key]
} }
return result
} }
set(value) { set(value) {
queue.async(flags: .barrier) { lock.withLock { dict in
self.dict[key] = value 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. /// 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? { func removeValue(forKey key: Key) -> Value? {
var value: Value? = nil return lock.withLock { dict in
queue.sync(flags: .barrier) { dict.removeValue(forKey: key)
value = dict.removeValue(forKey: key)
} }
return value
} }
func contains(key: Key) -> Bool { func contains(key: Key) -> Bool {
var value: Bool! return lock.withLock { dict in
queue.sync { dict.keys.contains(key)
value = dict.keys.contains(key)
} }
return value }
func withLock<R>(_ body: @Sendable (inout [Key: Value]) throws -> R) rethrows -> R where R: Sendable {
return try lock.withLock(body)
}
}
// TODO: replace this only with OSAllocatedUnfairLock
@available(iOS, obsoleted: 16.0)
fileprivate protocol Lock<State> {
associatedtype State
func withLock<R>(_ body: @Sendable (inout State) throws -> R) rethrows -> R where R: Sendable
}
@available(iOS 16.0, *)
extension OSAllocatedUnfairLock: Lock {
}
// from http://www.russbishop.net/the-law
fileprivate class UnfairLock<State>: Lock {
private var lock: UnsafeMutablePointer<os_unfair_lock>
private var state: State
init(initialState: State) {
self.state = initialState
self.lock = .allocate(capacity: 1)
self.lock.initialize(to: os_unfair_lock())
}
deinit {
self.lock.deallocate()
}
func withLock<R>(_ body: (inout State) throws -> R) rethrows -> R where R: Sendable {
os_unfair_lock_lock(lock)
defer { os_unfair_lock_unlock(lock) }
return try body(&state)
} }
} }

View File

@ -48,7 +48,7 @@ struct ComposeAutocompleteMentionsView: View {
@ObservedObject private var preferences = Preferences.shared @ObservedObject private var preferences = Preferences.shared
// can't use AccountProtocol because of associated type requirements // can't use AccountProtocol because of associated type requirements
@State private var accounts: [EitherAccount] = [] @State private var accounts: [AnyAccount] = []
@State private var searchRequest: URLSessionTask? @State private var searchRequest: URLSessionTask?
@ -56,26 +56,20 @@ struct ComposeAutocompleteMentionsView: View {
ScrollView(.horizontal) { ScrollView(.horizontal) {
// can't use LazyHStack because changing the contents of the ForEach causes the ScrollView to hang // can't use LazyHStack because changing the contents of the ForEach causes the ScrollView to hang
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach(accounts, id: \.id) { (account) in ForEach(accounts, id: \.value.id) { (account) in
Button { Button {
uiState.currentInput?.autocomplete(with: "@\(account.acct)") uiState.currentInput?.autocomplete(with: "@\(account.value.acct)")
} label: { } label: {
HStack(spacing: 4) { HStack(spacing: 4) {
ComposeAvatarImageView(url: account.avatar) ComposeAvatarImageView(url: account.value.avatar)
.frame(width: 30, height: 30) .frame(width: 30, height: 30)
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 30) .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 30)
VStack(alignment: .leading) { VStack(alignment: .leading) {
switch account { AccountDisplayNameLabel(account: account.value, fontSize: 14)
case let .pachyderm(underlying): .foregroundColor(Color(UIColor.label))
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)") Text(verbatim: "@\(account.value.acct)")
.font(.system(size: 12)) .font(.system(size: 12))
.foregroundColor(Color(UIColor.label)) .foregroundColor(Color(UIColor.label))
} }
@ -110,7 +104,7 @@ struct ComposeAutocompleteMentionsView: View {
request.predicate = NSPredicate(format: "displayName LIKE %@ OR acct LIKE %@", wildcardedQuery, wildcardedQuery) request.predicate = NSPredicate(format: "displayName LIKE %@ OR acct LIKE %@", wildcardedQuery, wildcardedQuery)
if let results = try? mastodonController.persistentContainer.viewContext.fetch(request) { if let results = try? mastodonController.persistentContainer.viewContext.fetch(request) {
loadAccounts(results.map { .coreData($0) }, query: query) loadAccounts(results.map { .init(value: $0) }, query: query)
} }
} }
@ -131,27 +125,27 @@ struct ComposeAutocompleteMentionsView: View {
DispatchQueue.main.async { DispatchQueue.main.async {
// if the query has changed, don't bother loading the now-outdated results // if the query has changed, don't bother loading the now-outdated results
if case .mention(query) = uiState.autocompleteState { if case .mention(query) = uiState.autocompleteState {
self.loadAccounts(accounts.map { .pachyderm($0) }, query: query) self.loadAccounts(accounts.map { .init(value: $0) }, query: query)
} }
} }
} }
} }
private func loadAccounts(_ accounts: [EitherAccount], query: String) { private func loadAccounts(_ accounts: [AnyAccount], query: String) {
// when sorting account suggestions, ignore the domain component of the acct unless the user is typing it themself // when sorting account suggestions, ignore the domain component of the acct unless the user is typing it themself
let ignoreDomain = !query.contains("@") let ignoreDomain = !query.contains("@")
self.accounts = self.accounts =
accounts.map { (account: EitherAccount) -> (EitherAccount, (matched: Bool, score: Int)) in accounts.map { (account) -> (AnyAccount, (matched: Bool, score: Int)) in
let fuzzyStr = ignoreDomain ? String(account.acct.split(separator: "@").first!) : account.acct let fuzzyStr = ignoreDomain ? String(account.value.acct.split(separator: "@").first!) : account.value.acct
let res = (account, FuzzyMatcher.match(pattern: query, str: fuzzyStr)) let res = (account, FuzzyMatcher.match(pattern: query, str: fuzzyStr))
return res return res
} }
.filter(\.1.matched) .filter(\.1.matched)
.map { (account, res) -> (EitherAccount, Int) in .map { (account, res) -> (AnyAccount, Int) in
// give higher weight to accounts that the user follows or is followed by // give higher weight to accounts that the user follows or is followed by
var score = res.score var score = res.score
if let relationship = mastodonController.persistentContainer.relationship(forAccount: account.id) { if let relationship = mastodonController.persistentContainer.relationship(forAccount: account.value.id) {
if relationship.following { if relationship.following {
score += 3 score += 3
} }
@ -165,39 +159,11 @@ struct ComposeAutocompleteMentionsView: View {
.map(\.0) .map(\.0)
} }
private enum EitherAccount: Equatable { private struct AnyAccount: Equatable {
case pachyderm(Account) let value: any AccountProtocol
case coreData(AccountMO)
var id: String { static func ==(lhs: AnyAccount, rhs: AnyAccount) -> Bool {
switch self { return lhs.value.id == rhs.value.id
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
}
}
static func ==(lhs: EitherAccount, rhs: EitherAccount) -> Bool {
return lhs.id == rhs.id
} }
} }
} }
@ -214,8 +180,14 @@ struct ComposeAutocompleteEmojisView: View {
HStack(alignment: expanded ? .top : .center, spacing: 0) { HStack(alignment: expanded ? .top : .center, spacing: 0) {
if case let .emoji(query) = uiState.autocompleteState { if case let .emoji(query) = uiState.autocompleteState {
emojiList(query: query) emojiList(query: query)
.animation(.default, value: expanded)
.transition(.move(edge: .bottom)) .transition(.move(edge: .bottom))
.onReceive(uiState.$autocompleteState, perform: queryChanged)
.onAppear {
if uiState.shouldEmojiAutocompletionBeginExpanded {
expanded = true
uiState.shouldEmojiAutocompletionBeginExpanded = false
}
}
} else { } else {
// when the autocomplete view is animating out, the autocomplete state is nil // when the autocomplete view is animating out, the autocomplete state is nil
// add a spacer so the expand button remains on the right // add a spacer so the expand button remains on the right
@ -231,18 +203,28 @@ struct ComposeAutocompleteEmojisView: View {
@ViewBuilder @ViewBuilder
private func emojiList(query: String) -> some View { private func emojiList(query: String) -> some View {
if expanded { if expanded {
EmojiPickerWrapper(searchQuery: query) verticalGrid
.frame(height: 150) .frame(height: 150)
} else { } else {
horizontalScrollView horizontalScrollView
.onReceive(uiState.$autocompleteState, perform: queryChanged) }
.onAppear { }
if uiState.shouldEmojiAutocompletionBeginExpanded {
expanded = true private var verticalGrid: some View {
uiState.shouldEmojiAutocompletionBeginExpanded = false ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 30), spacing: 4)]) {
ForEach(emojis, id: \.shortcode) { (emoji) in
Button {
uiState.currentInput?.autocomplete(with: ":\(emoji.shortcode):")
} label: {
CustomEmojiImageView(emoji: emoji)
.frame(height: 30)
} }
} }
}
.padding(.all, 8)
} }
.frame(maxWidth: .infinity)
} }
private var horizontalScrollView: some View { private var horizontalScrollView: some View {
@ -260,7 +242,6 @@ struct ComposeAutocompleteEmojisView: View {
} }
} }
.frame(height: 30) .frame(height: 30)
.padding(.vertical, 8)
} }
.animation(.linear(duration: 0.2), value: emojis) .animation(.linear(duration: 0.2), value: emojis)
@ -273,30 +254,41 @@ struct ComposeAutocompleteEmojisView: View {
private var toggleExpandedButton: some View { private var toggleExpandedButton: some View {
Button { Button {
expanded.toggle() withAnimation {
expanded.toggle()
}
} label: { } label: {
Image(systemName: expanded ? "chevron.down" : "chevron.up") Image(systemName: "chevron.down")
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.rotationEffect(expanded ? .zero : .degrees(180))
} }
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
} }
private func queryChanged(_ autocompleteState: ComposeUIState.AutocompleteState?) { private func queryChanged(_ autocompleteState: ComposeUIState.AutocompleteState?) {
guard case let .emoji(query) = autocompleteState, guard case let .emoji(query) = autocompleteState else {
!query.isEmpty else {
emojis = [] emojis = []
return return
} }
mastodonController.getCustomEmojis { (emojis) in mastodonController.getCustomEmojis { (emojis) in
self.emojis = var emojis = emojis
emojis.map { (emoji) -> (Emoji, (matched: Bool, score: Int)) in if !query.isEmpty {
(emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode)) emojis =
} emojis.map { (emoji) -> (Emoji, (matched: Bool, score: Int)) in
.filter(\.1.matched) (emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode))
.sorted { $0.1.score > $1.1.score } }
.map(\.0) .filter(\.1.matched)
.sorted { $0.1.score > $1.1.score }
.map(\.0)
}
var shortcodes = Set<String>()
self.emojis = []
for emoji in emojis where !shortcodes.contains(emoji.shortcode) {
self.emojis.append(emoji)
shortcodes.insert(emoji.shortcode)
}
} }
} }
} }

View File

@ -23,6 +23,7 @@ struct ComposeReplyContentView: UIViewRepresentable {
view.setTextFrom(status: status) view.setTextFrom(status: status)
view.isUserInteractionEnabled = false view.isUserInteractionEnabled = false
view.backgroundColor = .clear view.backgroundColor = .clear
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return view return view
} }

View File

@ -11,8 +11,8 @@ import SwiftUI
struct ComposeReplyView: View { struct ComposeReplyView: View {
let status: StatusMO let status: StatusMO
let stackPadding: CGFloat let stackPadding: CGFloat
let outerMinY: CGFloat
@State private var displayNameHeight: CGFloat?
@State private var contentHeight: CGFloat? @State private var contentHeight: CGFloat?
@ObservedObject private var preferences = Preferences.shared @ObservedObject private var preferences = Preferences.shared
@ -37,23 +37,25 @@ struct ComposeReplyView: View {
Spacer() Spacer()
} }
.background(GeometryReader { proxy in
ComposeReplyContentView(status: status) { (newHeight) in Color.clear
self.contentHeight = newHeight .preference(key: DisplayNameHeightPrefKey.self, value: proxy.size.height)
.onPreferenceChange(DisplayNameHeightPrefKey.self) { newValue in
displayNameHeight = newValue
}
})
ComposeReplyContentView(status: status) { newHeight in
contentHeight = newHeight
} }
.offset(x: -4, y: -8) .frame(height: contentHeight ?? 0)
.padding(.bottom, -8)
} }
.frame(height: max(50, contentHeight ?? 0) + 8)
} }
.padding(.bottom, -8)
} }
private func replyAvatarImage(geometry: GeometryProxy) -> some View { private func replyAvatarImage(geometry: GeometryProxy) -> some View {
// using named coordinate spaces produces an incorrect scroll offset on iOS 13, let scrollOffset = -geometry.frame(in: .named(ComposeView.coordinateSpaceOutsideOfScrollView)).minY
// so simply compare the geometry inside and outside the scroll view in the global coordinate space
let scrollOffset = outerMinY - geometry.frame(in: .global).minY
// add stackPadding so that the image is always at least stackPadding away from the top // add stackPadding so that the image is always at least stackPadding away from the top
var offset = scrollOffset + stackPadding var offset = scrollOffset + stackPadding
@ -61,7 +63,7 @@ struct ComposeReplyView: View {
offset = max(offset, 0) offset = max(offset, 0)
// subtract 50, because we care about where the bottom of the view is but the offset is relative to the top of the view // subtract 50, because we care about where the bottom of the view is but the offset is relative to the top of the view
let maxOffset = (contentHeight ?? 0) - 50 let maxOffset = (contentHeight ?? 0) + (displayNameHeight ?? 0) - 50
// once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content // once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content
offset = min(offset, maxOffset) offset = min(offset, maxOffset)
@ -74,6 +76,13 @@ struct ComposeReplyView: View {
} }
private struct DisplayNameHeightPrefKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
//struct ComposeReplyView_Previews: PreviewProvider { //struct ComposeReplyView_Previews: PreviewProvider {
// static var previews: some View { // static var previews: some View {
// ComposeReplyView() // ComposeReplyView()

View File

@ -42,6 +42,8 @@ import Combine
} }
struct ComposeView: View { struct ComposeView: View {
static let coordinateSpaceOutsideOfScrollView = "coordinateSpaceOutsideOfScrollView"
@ObservedObject var draft: Draft @ObservedObject var draft: Draft
@EnvironmentObject var mastodonController: MastodonController @EnvironmentObject var mastodonController: MastodonController
@EnvironmentObject var uiState: ComposeUIState @EnvironmentObject var uiState: ComposeUIState
@ -77,20 +79,12 @@ struct ComposeView: View {
} }
var body: some View { var body: some View {
mostOfTheBody.toolbar {
ToolbarItem(placement: .cancellationAction) { cancelButton }
ToolbarItem(placement: .confirmationAction) { postButton }
}
}
var mostOfTheBody: some View {
ZStack(alignment: .top) { ZStack(alignment: .top) {
GeometryReader { (outer) in ScrollView(.vertical) {
ScrollView(.vertical) { mainStack
mainStack(outerMinY: outer.frame(in: .global).minY)
}
.scrollDismissesKeyboardInteractivelyIfAvailable()
} }
.coordinateSpace(name: ComposeView.coordinateSpaceOutsideOfScrollView)
.scrollDismissesKeyboardInteractivelyIfAvailable()
if let poster = poster { if let poster = poster {
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149 // can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
@ -108,6 +102,10 @@ struct ComposeView: View {
dismissButton: .default(Text("OK")) dismissButton: .default(Text("OK"))
) )
} }
.toolbar {
ToolbarItem(placement: .cancellationAction) { cancelButton }
ToolbarItem(placement: .confirmationAction) { postButton }
}
} }
@ViewBuilder @ViewBuilder
@ -122,14 +120,13 @@ struct ComposeView: View {
.animation(.default, value: uiState.autocompleteState) .animation(.default, value: uiState.autocompleteState)
} }
func mainStack(outerMinY: CGFloat) -> some View { var mainStack: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
if let id = draft.inReplyToID, if let id = draft.inReplyToID,
let status = mastodonController.persistentContainer.status(for: id) { let status = mastodonController.persistentContainer.status(for: id) {
ComposeReplyView( ComposeReplyView(
status: status, status: status,
stackPadding: stackPadding, stackPadding: stackPadding
outerMinY: outerMinY
) )
} }

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
import WebURLFoundationExtras
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(URL(emoji.url)!) { [weak self] (_, image) in
guard let image = image else { return }
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,131 +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: AnyObject {
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 layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let hSizeClass = environment.traitCollection.horizontalSizeClass
let itemWidth = NSCollectionLayoutDimension.fractionalWidth(1.0 / (hSizeClass == .compact ? 10 : 20))
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
return 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.currentInput?.autocomplete(with: ":\(emoji.shortcode):")
uiState.autocompleteState = nil
}
}
}

View File

@ -52,7 +52,7 @@ class FeaturedProfileCollectionViewCell: UICollectionViewCell {
displayNameLabel.updateForAccountDisplayName(account: account) displayNameLabel.updateForAccountDisplayName(account: account)
noteTextView.setTextFromHtml(account.note) noteTextView.setTextFromHtml(account.note)
noteTextView.setEmojis(account.emojis) noteTextView.setEmojis(account.emojis, identifier: account.id)
avatarImageView.image = nil avatarImageView.image = nil
if let avatar = account.avatar { if let avatar = account.avatar {

View File

@ -140,9 +140,12 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
let notchedDeviceTopInsets: [CGFloat] = [ let notchedDeviceTopInsets: [CGFloat] = [
44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max 44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max
48, // iPhone XR, 11 48, // iPhone XR, 11
47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max 47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max, 14, 14 Plus
50, // iPhone 12 mini, 13 mini 50, // iPhone 12 mini, 13 mini
] ]
let pillDeviceTopInsets: [CGFloat] = [
59, // iPhone 14 Pro, 14 Pro Max
]
if notchedDeviceTopInsets.contains(view.safeAreaInsets.top) { if notchedDeviceTopInsets.contains(view.safeAreaInsets.top) {
// the notch width is not the same for the iPhones 13, // the notch width is not the same for the iPhones 13,
// but what we actually want is the same offset from the edges // but what we actually want is the same offset from the edges
@ -152,6 +155,11 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
let offset = (earWidth - shareButton.bounds.width) / 2 let offset = (earWidth - shareButton.bounds.width) / 2
shareButtonLeadingConstraint.constant = offset shareButtonLeadingConstraint.constant = offset
closeButtonTrailingConstraint.constant = offset closeButtonTrailingConstraint.constant = offset
} else if pillDeviceTopInsets.contains(view.safeAreaInsets.top) {
shareButtonLeadingConstraint.constant = 24
shareButtonTopConstraint.constant = 24
closeButtonTrailingConstraint.constant = 24
closeButtonTopConstraint.constant = 24
} }
} }

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21225" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/> <device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21207"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies> </dependencies>
@ -67,6 +67,8 @@
<constraint firstItem="pnA-ne-k0v" firstAttribute="top" secondItem="kHo-B9-R7a" secondAttribute="top" constant="16" id="ImD-2H-0XK"/> <constraint firstItem="pnA-ne-k0v" firstAttribute="top" secondItem="kHo-B9-R7a" secondAttribute="top" constant="16" id="ImD-2H-0XK"/>
<constraint firstAttribute="trailing" secondItem="pnA-ne-k0v" secondAttribute="trailing" constant="16" id="JFe-ig-3Ic"/> <constraint firstAttribute="trailing" secondItem="pnA-ne-k0v" secondAttribute="trailing" constant="16" id="JFe-ig-3Ic"/>
<constraint firstItem="vhp-0u-Q0S" firstAttribute="leading" secondItem="kHo-B9-R7a" secondAttribute="leading" constant="16" id="MJx-2r-p0k"/> <constraint firstItem="vhp-0u-Q0S" firstAttribute="leading" secondItem="kHo-B9-R7a" secondAttribute="leading" constant="16" id="MJx-2r-p0k"/>
<constraint firstAttribute="bottom" secondItem="vhp-0u-Q0S" secondAttribute="bottom" id="fi6-JS-UmZ"/>
<constraint firstAttribute="bottom" secondItem="pnA-ne-k0v" secondAttribute="bottom" id="hEU-VY-WTd"/>
<constraint firstItem="vhp-0u-Q0S" firstAttribute="top" secondItem="kHo-B9-R7a" secondAttribute="top" constant="16" id="sgG-dC-xXP"/> <constraint firstItem="vhp-0u-Q0S" firstAttribute="top" secondItem="kHo-B9-R7a" secondAttribute="top" constant="16" id="sgG-dC-xXP"/>
</constraints> </constraints>
</view> </view>

View File

@ -7,6 +7,7 @@
// //
import UIKit import UIKit
import ScreenCorners
class AccountSwitchingContainerViewController: UIViewController { class AccountSwitchingContainerViewController: UIViewController {
@ -49,14 +50,30 @@ class AccountSwitchingContainerViewController: UIViewController {
} else { } else {
let sign: CGFloat = direction == .downwards ? -1 : 1 let sign: CGFloat = direction == .downwards ? -1 : 1
let newInitialOffset = sign * view.bounds.height let newInitialOffset = sign * view.bounds.height
let scale: CGFloat = 0.75
newRoot.view.transform = CGAffineTransform(translationX: 0, y: newInitialOffset) newRoot.view.transform = CGAffineTransform(translationX: 0, y: newInitialOffset).scaledBy(x: 0.9, y: 0.9)
newRoot.view.layer.masksToBounds = true
newRoot.view.layer.cornerCurve = .continuous
newRoot.view.layer.cornerRadius = view.window?.screen.displayCornerRadius ?? 0
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) { oldRoot.view.layer.masksToBounds = true
oldRoot.view.layer.cornerCurve = .continuous
oldRoot.view.layer.cornerRadius = view.window?.screen.displayCornerRadius ?? 0
// only one edge is affected in each direction, i have no idea why
if direction == .upwards {
oldRoot.additionalSafeAreaInsets.bottom = view.safeAreaInsets.bottom
} else {
oldRoot.additionalSafeAreaInsets.top = view.safeAreaInsets.top
}
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseInOut) {
oldRoot.view.transform = CGAffineTransform(translationX: 0, y: -newInitialOffset).scaledBy(x: scale, y: scale)
newRoot.view.transform = .identity newRoot.view.transform = .identity
oldRoot.view.transform = CGAffineTransform(translationX: 0, y: -newInitialOffset)
} completion: { (_) in } completion: { (_) in
oldRoot.removeViewAndController() oldRoot.removeViewAndController()
newRoot.view.layer.masksToBounds = false
} }
} }
} }

View File

@ -9,7 +9,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
class NotificationsTableViewController: DiffableTimelineLikeTableViewController<NotificationsTableViewController.Section, NotificationGroup> { class NotificationsTableViewController: DiffableTimelineLikeTableViewController<NotificationsTableViewController.Section, NotificationsTableViewController.Item> {
private let statusCell = "statusCell" private let statusCell = "statusCell"
private let actionGroupCell = "actionGroupCell" private let actionGroupCell = "actionGroupCell"
@ -56,7 +56,12 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
// MARK: - DiffableTimelineLikeTableViewController // MARK: - DiffableTimelineLikeTableViewController
override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ group: NotificationGroup) -> UITableViewCell? { override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? {
if case .loadingIndicator = item {
return self.loadingIndicatorCell(indexPath: indexPath)
}
let group = item.group!
switch group.kind { switch group.kind {
case .mention: case .mention:
guard let notification = group.notifications.first, guard let notification = group.notifications.first,
@ -107,18 +112,18 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
case let .failure(error): case let .failure(error):
completion(.failure(.client(error))) completion(.failure(.client(error)))
case let .success(notifications, _): case let .success(notifications, pagination):
let groups = NotificationGroup.createGroups(notifications: notifications, only: self.groupTypes) let groups = NotificationGroup.createGroups(notifications: notifications, only: self.groupTypes)
if !notifications.isEmpty { if !notifications.isEmpty {
self.newer = .after(id: notifications.first!.id, count: nil) self.newer = pagination?.newer ?? .after(id: notifications.first!.id, count: nil)
self.older = .before(id: notifications.last!.id, count: nil) self.older = pagination?.older ?? .before(id: notifications.last!.id, count: nil)
} }
self.mastodonController.persistentContainer.addAll(notifications: notifications) { self.mastodonController.persistentContainer.addAll(notifications: notifications) {
var snapshot = Snapshot() var snapshot = Snapshot()
snapshot.appendSections([.notifications]) snapshot.appendSections([.notifications])
snapshot.appendItems(groups, toSection: .notifications) snapshot.appendItems(groups.map { .notificationGroup($0) }, toSection: .notifications)
completion(.success(snapshot)) completion(.success(snapshot))
} }
} }
@ -137,19 +142,19 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
case let .failure(error): case let .failure(error):
completion(.failure(.client(error))) completion(.failure(.client(error)))
case let .success(newNotifications, _): case let .success(newNotifications, pagination):
if !newNotifications.isEmpty { if !newNotifications.isEmpty {
self.older = .before(id: newNotifications.last!.id, count: nil) self.older = pagination?.older ?? .before(id: newNotifications.last!.id, count: nil)
} }
let olderGroups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes) let olderGroups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) { self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
let existingGroups = currentSnapshot().itemIdentifiers let existingGroups = currentSnapshot().itemIdentifiers.compactMap(\.group)
let merged = NotificationGroup.mergeGroups(first: existingGroups, second: olderGroups, only: self.groupTypes) let merged = NotificationGroup.mergeGroups(first: existingGroups, second: olderGroups, only: self.groupTypes)
var snapshot = NSDiffableDataSourceSnapshot<Section, NotificationGroup>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.notifications]) snapshot.appendSections([.notifications])
snapshot.appendItems(merged, toSection: .notifications) snapshot.appendItems(merged.map { .notificationGroup($0) }, toSection: .notifications)
completion(.success(snapshot)) completion(.success(snapshot))
} }
} }
@ -168,22 +173,22 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
case let .failure(error): case let .failure(error):
completion(.failure(.client(error))) completion(.failure(.client(error)))
case let .success(newNotifications, _): case let .success(newNotifications, pagination):
guard !newNotifications.isEmpty else { guard !newNotifications.isEmpty else {
completion(.failure(.allCaughtUp)) completion(.failure(.allCaughtUp))
return return
} }
self.newer = .after(id: newNotifications.first!.id, count: nil) self.newer = pagination?.newer ?? .after(id: newNotifications.first!.id, count: nil)
let newerGroups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes) let newerGroups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) { self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
let existingGroups = currentSnapshot().itemIdentifiers let existingGroups = currentSnapshot().itemIdentifiers.compactMap(\.group)
let merged = NotificationGroup.mergeGroups(first: newerGroups, second: existingGroups, only: self.groupTypes) let merged = NotificationGroup.mergeGroups(first: newerGroups, second: existingGroups, only: self.groupTypes)
var snapshot = NSDiffableDataSourceSnapshot<Section, NotificationGroup>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.notifications]) snapshot.appendSections([.notifications])
snapshot.appendItems(merged, toSection: .notifications) snapshot.appendItems(merged.map { .notificationGroup($0) }, toSection: .notifications)
completion(.success(snapshot)) completion(.success(snapshot))
} }
} }
@ -191,9 +196,12 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
} }
private func dismissNotificationsInGroup(at indexPath: IndexPath, completion: (() -> Void)? = nil) { private func dismissNotificationsInGroup(at indexPath: IndexPath, completion: (() -> Void)? = nil) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return } guard let item = dataSource.itemIdentifier(for: indexPath),
let notifications = item.group?.notifications else {
return
}
let group = DispatchGroup() let group = DispatchGroup()
item.notifications notifications
.map { Pachyderm.Notification.dismiss(id: $0.id) } .map { Pachyderm.Notification.dismiss(id: $0.id) }
.forEach { (request) in .forEach { (request) in
group.enter() group.enter()
@ -241,9 +249,23 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
} }
extension NotificationsTableViewController { extension NotificationsTableViewController {
enum Section: CaseIterable, Hashable { enum Section: DiffableTimelineLikeSection {
case loadingIndicator
case notifications case notifications
} }
enum Item: DiffableTimelineLikeItem {
case loadingIndicator
case notificationGroup(NotificationGroup)
var group: NotificationGroup? {
switch self {
case .loadingIndicator:
return nil
case .notificationGroup(let group):
return group
}
}
}
} }
extension NotificationsTableViewController: TuskerNavigationDelegate { extension NotificationsTableViewController: TuskerNavigationDelegate {
@ -265,7 +287,7 @@ extension NotificationsTableViewController: StatusTableViewCellDelegate {
extension NotificationsTableViewController: UITableViewDataSourcePrefetching { extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { for indexPath in indexPaths {
guard let group = dataSource.itemIdentifier(for: indexPath) else { continue } guard let group = dataSource.itemIdentifier(for: indexPath)?.group else { continue }
for notification in group.notifications { for notification in group.notifications {
guard let avatar = notification.account.avatar else { continue } guard let avatar = notification.account.avatar else { continue }
ImageCache.avatars.fetchIfNotCached(avatar) ImageCache.avatars.fetchIfNotCached(avatar)
@ -275,7 +297,7 @@ extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { for indexPath in indexPaths {
guard let group = dataSource.itemIdentifier(for: indexPath) else { continue } guard let group = dataSource.itemIdentifier(for: indexPath)?.group else { continue }
for notification in group.notifications { for notification in group.notifications {
guard let avatar = notification.account.avatar else { continue } guard let avatar = notification.account.avatar else { continue }
ImageCache.avatars.cancelWithoutCallback(avatar) ImageCache.avatars.cancelWithoutCallback(avatar)

View File

@ -62,7 +62,7 @@ class OnboardingViewController: UINavigationController {
} }
// construct a temporary UserAccountInfo instance for the MastodonController to use to fetch its own account // construct a temporary UserAccountInfo instance for the MastodonController to use to fetch its own account
let tempAccountInfo = LocalData.UserAccountInfo(id: "temp", instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: nil, accessToken: accessToken) let tempAccountInfo = LocalData.UserAccountInfo(tempInstanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, accessToken: accessToken)
mastodonController.accountInfo = tempAccountInfo mastodonController.accountInfo = tempAccountInfo
let ownAccount: Account let ownAccount: Account

View File

@ -48,7 +48,7 @@ struct PreferencesView: View {
indices.remove(index) indices.remove(index)
} }
localData.accounts.remove(atOffsets: indices) indices.forEach { localData.removeAccount(localData.accounts[$0]) }
if logoutFromCurrent { if logoutFromCurrent {
self.logoutPressed() self.logoutPressed()

View File

@ -60,14 +60,17 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
// MARK: - DiffableTimelineLikeTableViewController // MARK: - DiffableTimelineLikeTableViewController
override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? { override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? {
let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell switch item {
case .loadingIndicator:
cell.delegate = self return self.loadingIndicatorCell(indexPath: indexPath)
// todo: dataSource.sectionIdentifier is only available on iOS 15
cell.showPinned = dataSource.snapshot().indexOfSection(.pinned) == indexPath.section case let .status(id: id, state: state, pinned: pinned):
cell.updateUI(statusID: item.id, state: item.state) let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell
cell.delegate = self
return cell cell.showPinned = pinned
cell.updateUI(statusID: id, state: state)
return cell
}
} }
override func loadInitialItems(completion: @escaping (LoadResult) -> Void) { override func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
@ -85,16 +88,16 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
case let .failure(error): case let .failure(error):
completion(.failure(.client(error))) completion(.failure(.client(error)))
case let .success(statuses, _): case let .success(statuses, pagination):
if !statuses.isEmpty { if !statuses.isEmpty {
self.newer = .after(id: statuses.first!.id, count: nil) self.newer = pagination?.newer ?? .after(id: statuses.first!.id, count: nil)
self.older = .before(id: statuses.last!.id, count: nil) self.older = pagination?.older ?? .before(id: statuses.last!.id, count: nil)
} }
self.mastodonController.persistentContainer.addAll(statuses: statuses) { self.mastodonController.persistentContainer.addAll(statuses: statuses) {
DispatchQueue.main.async { DispatchQueue.main.async {
var snapshot = self.dataSource.snapshot() var snapshot = self.dataSource.snapshot()
snapshot.appendItems(statuses.map { Item(id: $0.id, state: .unknown, pinned: false) }, toSection: .statuses) snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, pinned: false) }, toSection: .statuses)
if self.kind == .statuses { if self.kind == .statuses {
self.loadPinnedStatuses(snapshot: { snapshot }, completion: completion) self.loadPinnedStatuses(snapshot: { snapshot }, completion: completion)
} else { } else {
@ -122,7 +125,7 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
DispatchQueue.main.async { DispatchQueue.main.async {
var snapshot = snapshot() var snapshot = snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .pinned)) snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .pinned))
snapshot.appendItems(statuses.map { Item(id: $0.id, state: .unknown, pinned: true) }, toSection: .pinned) snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, pinned: true) }, toSection: .pinned)
completion(.success(snapshot)) completion(.success(snapshot))
} }
} }
@ -141,17 +144,17 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
case let .failure(error): case let .failure(error):
completion(.failure(.client(error))) completion(.failure(.client(error)))
case let .success(statuses, _): case let .success(statuses, pagination):
guard !statuses.isEmpty else { guard !statuses.isEmpty else {
completion(.failure(.noOlder)) completion(.failure(.noOlder))
return return
} }
self.older = .before(id: statuses.last!.id, count: nil) self.older = pagination?.older ?? .before(id: statuses.last!.id, count: nil)
self.mastodonController.persistentContainer.addAll(statuses: statuses) { self.mastodonController.persistentContainer.addAll(statuses: statuses) {
var snapshot = currentSnapshot() var snapshot = currentSnapshot()
snapshot.appendItems(statuses.map { Item(id: $0.id, state: .unknown, pinned: false) }, toSection: .statuses) snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, pinned: false) }, toSection: .statuses)
completion(.success(snapshot)) completion(.success(snapshot))
} }
} }
@ -170,17 +173,17 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
case let .failure(error): case let .failure(error):
completion(.failure(.client(error))) completion(.failure(.client(error)))
case let .success(statuses, _): case let .success(statuses, pagination):
guard !statuses.isEmpty else { guard !statuses.isEmpty else {
completion(.failure(.allCaughtUp)) completion(.failure(.allCaughtUp))
return return
} }
self.newer = .after(id: statuses.first!.id, count: nil) self.newer = pagination?.newer ?? .after(id: statuses.first!.id, count: nil)
self.mastodonController.persistentContainer.addAll(statuses: statuses) { self.mastodonController.persistentContainer.addAll(statuses: statuses) {
var snapshot = currentSnapshot() var snapshot = currentSnapshot()
let items = statuses.map { Item(id: $0.id, state: .unknown, pinned: false) } let items = statuses.map { Item.status(id: $0.id, state: .unknown, pinned: false) }
if let first = snapshot.itemIdentifiers(inSection: .statuses).first { if let first = snapshot.itemIdentifiers(inSection: .statuses).first {
snapshot.insertItems(items, beforeItem: first) snapshot.insertItems(items, beforeItem: first)
} else { } else {
@ -239,22 +242,22 @@ extension ProfileStatusesViewController {
} }
extension ProfileStatusesViewController { extension ProfileStatusesViewController {
enum Section: CaseIterable { enum Section: DiffableTimelineLikeSection {
case loadingIndicator
case pinned case pinned
case statuses case statuses
} }
struct Item: Hashable { enum Item: DiffableTimelineLikeItem {
let id: String case loadingIndicator
let state: StatusState case status(id: String, state: StatusState, pinned: Bool)
let pinned: Bool
static func ==(lhs: Item, rhs: Item) -> Bool { var id: String? {
return lhs.id == rhs.id && lhs.pinned == rhs.pinned switch self {
} case .loadingIndicator:
return nil
func hash(into hasher: inout Hasher) { case .status(id: let id, state: _, pinned: _):
hasher.combine(id) return id
hasher.combine(pinned) }
} }
} }
} }

View File

@ -97,6 +97,9 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? { override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? {
switch item { switch item {
case .loadingIndicator:
return self.loadingIndicatorCell(indexPath: indexPath)
case let .status(id: id, state: state): case let .status(id: id, state: state):
let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell
@ -139,15 +142,18 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
case let .failure(error): case let .failure(error):
completion(.failure(.client(error))) completion(.failure(.client(error)))
case let .success(statuses, _): case let .success(statuses, pagination):
if !statuses.isEmpty { if !statuses.isEmpty {
self.newer = .after(id: statuses.first!.id, count: nil) self.newer = pagination?.newer ?? .after(id: statuses.first!.id, count: nil)
self.older = .before(id: statuses.last!.id, count: nil) self.older = pagination?.older ?? .before(id: statuses.last!.id, count: nil)
} }
self.mastodonController.persistentContainer.addAll(statuses: statuses) { self.mastodonController.persistentContainer.addAll(statuses: statuses) {
DispatchQueue.main.async { DispatchQueue.main.async {
var snapshot = self.dataSource.snapshot() var snapshot = self.dataSource.snapshot()
if snapshot.sectionIdentifiers.contains(.loadingIndicator) {
snapshot.deleteSections([.loadingIndicator])
}
snapshot.deleteSections([.statuses, .footer]) snapshot.deleteSections([.statuses, .footer])
snapshot.appendSections([.statuses, .footer]) snapshot.appendSections([.statuses, .footer])
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown) }, toSection: .statuses) snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown) }, toSection: .statuses)
@ -183,9 +189,9 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
case let .failure(error): case let .failure(error):
completion(.failure(.client(error))) completion(.failure(.client(error)))
case let .success(statuses, _): case let .success(statuses, pagination):
if !statuses.isEmpty { if !statuses.isEmpty {
self.older = .before(id: statuses.last!.id, count: nil) self.older = pagination?.older ?? .before(id: statuses.last!.id, count: nil)
} }
self.mastodonController.persistentContainer.addAll(statuses: statuses) { self.mastodonController.persistentContainer.addAll(statuses: statuses) {
@ -210,13 +216,13 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
case let .failure(error): case let .failure(error):
completion(.failure(.client(error))) completion(.failure(.client(error)))
case let .success(statuses, _): case let .success(statuses, pagination):
guard !statuses.isEmpty else { guard !statuses.isEmpty else {
completion(.failure(.allCaughtUp)) completion(.failure(.allCaughtUp))
return return
} }
self.newer = .after(id: statuses.first!.id, count: nil) self.newer = pagination?.newer ?? .after(id: statuses.first!.id, count: nil)
self.mastodonController.persistentContainer.addAll(statuses: statuses) { self.mastodonController.persistentContainer.addAll(statuses: statuses) {
var snapshot = currentSnapshot() var snapshot = currentSnapshot()
@ -245,12 +251,14 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
} }
extension TimelineTableViewController { extension TimelineTableViewController {
enum Section: Hashable, CaseIterable { enum Section: DiffableTimelineLikeSection {
case loadingIndicator
case header case header
case statuses case statuses
case footer case footer
} }
enum Item: Hashable { enum Item: DiffableTimelineLikeItem {
case loadingIndicator
case status(id: String, state: StatusState) case status(id: String, state: StatusState)
case confirmLoadMore case confirmLoadMore
case publicTimelineDescription(local: Bool) case publicTimelineDescription(local: Bool)
@ -270,13 +278,15 @@ extension TimelineTableViewController {
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
switch self { switch self {
case let .status(id: id, state: _): case .loadingIndicator:
hasher.combine(0) hasher.combine(0)
case let .status(id: id, state: _):
hasher.combine(1)
hasher.combine(id) hasher.combine(id)
case .confirmLoadMore: case .confirmLoadMore:
hasher.combine(1)
case let .publicTimelineDescription(local: local):
hasher.combine(2) hasher.combine(2)
case let .publicTimelineDescription(local: local):
hasher.combine(3)
hasher.combine(local) hasher.combine(local)
} }
} }

View File

@ -0,0 +1,533 @@
//
// CustomAlertController.swift
// Tusker
//
// Created by Shadowfacts on 9/17/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
class CustomAlertController: UIViewController {
private let config: Configuration
fileprivate var blurView: UIVisualEffectView!
fileprivate var dimmingView: UIView!
fileprivate var buttonsStack: UIStackView!
fileprivate var actionsView: CustomAlertActionsView!
init(config: Configuration) {
self.config = config
super.init(nibName: nil, bundle: nil)
transitioningDelegate = self
modalPresentationStyle = .custom
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
dimmingView = UIView()
dimmingView.backgroundColor = .black.withAlphaComponent(0.2)
dimmingView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(dimmingView)
NSLayoutConstraint.activate([
dimmingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
dimmingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
dimmingView.topAnchor.constraint(equalTo: view.topAnchor),
dimmingView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
blurView.backgroundColor = .systemBackground
blurView.layer.cornerRadius = 15
blurView.layer.cornerCurve = .continuous
blurView.layer.masksToBounds = true
blurView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(blurView)
NSLayoutConstraint.activate([
blurView.widthAnchor.constraint(equalToConstant: 270),
blurView.heightAnchor.constraint(greaterThanOrEqualToConstant: 100),
blurView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
blurView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 0
stack.alignment = .fill
stack.translatesAutoresizingMaskIntoConstraints = false
blurView.contentView.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: blurView.contentView.leadingAnchor),
blurView.contentView.trailingAnchor.constraint(equalTo: stack.trailingAnchor),
stack.topAnchor.constraint(equalTo: blurView.contentView.topAnchor, constant: 16),
stack.bottomAnchor.constraint(equalTo: blurView.contentView.bottomAnchor),
])
let titleLabel = UILabel()
titleLabel.text = config.title
titleLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0)
titleLabel.numberOfLines = 0
titleLabel.textAlignment = .center
stack.addArrangedSubview(titleLabel)
stack.addSpacer(length: 8)
stack.addArrangedSubview(config.content)
stack.addSpacer(length: 16)
let separator = UIView()
separator.tag = ViewTags.customAlertSeparator
separator.backgroundColor = .separator
stack.addArrangedSubview(separator)
NSLayoutConstraint.activate([
separator.widthAnchor.constraint(equalTo: stack.widthAnchor),
separator.heightAnchor.constraint(equalToConstant: 0.5),
])
actionsView = CustomAlertActionsView(config: config, dismiss: { [unowned self] in
self.dismiss(animated: true)
})
stack.addArrangedSubview(actionsView)
NSLayoutConstraint.activate([
actionsView.widthAnchor.constraint(equalTo: stack.widthAnchor),
])
}
struct Configuration {
var title: String
var content: UIView
var actions: [Action]
}
struct Action {
var title: String?
var image: UIImage?
var style: Style
var handler: (() -> Void)?
var isSecondaryMenu: Bool = false
init(title: String?, image: UIImage? = nil, style: Style, handler: (() -> Void)?) {
self.title = title
self.image = image
self.style = style
self.handler = handler
}
enum Style {
case `default`, cancel, destructive, menu([MenuAction])
}
}
struct MenuAction {
var title: String
var subtitle: String?
var image: UIImage?
var handler: () -> Void
}
}
class CustomAlertActionsView: UIControl {
private let dismiss: () -> Void
private let stack = UIStackView()
private var actionButtons: [CustomAlertActionButton] = []
private var labelWrapperWidthConstraints: [NSLayoutConstraint] = []
// the actions from the config but reordered to match labelWrappers order
private var reorderedActions: [CustomAlertController.Action] = []
private var separators: [UIView] = []
private var separatorSizeConstraints: [NSLayoutConstraint] = []
private let generator = UISelectionFeedbackGenerator()
private var currentSelectedActionIndex: Int?
init(config: CustomAlertController.Configuration, dismiss: @escaping () -> Void) {
self.dismiss = dismiss
super.init(frame: .zero)
stack.alignment = .fill
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: leadingAnchor),
stack.trailingAnchor.constraint(equalTo: trailingAnchor),
stack.topAnchor.constraint(equalTo: topAnchor),
stack.bottomAnchor.constraint(equalTo: bottomAnchor),
])
for action in config.actions {
let button = CustomAlertActionButton(action: action, dismiss: dismiss)
button.isAccessibilityElement = true
button.accessibilityTraits = .button
button.accessibilityRespondsToUserInteraction = true
button.accessibilityLabel = action.title
if action.isSecondaryMenu {
button.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true
}
if case .cancel = action.style {
actionButtons.insert(button, at: 0)
reorderedActions.insert(action, at: 0)
} else {
actionButtons.append(button)
reorderedActions.append(action)
}
}
var first = true
for (action, button) in zip(reorderedActions, actionButtons) {
if first {
first = false
} else if !action.isSecondaryMenu {
let separator = UIView()
separator.tag = ViewTags.customAlertSeparator
separator.backgroundColor = .separator
stack.addArrangedSubview(separator)
separators.append(separator)
}
if action.isSecondaryMenu {
// prev button
let prev = stack.arrangedSubviews.last!
stack.removeArrangedSubview(prev)
let separator = UIView()
separator.tag = ViewTags.customAlertSeparator
separator.backgroundColor = .separator
separator.widthAnchor.constraint(equalToConstant: 0.5).isActive = true
let hStack = UIStackView(arrangedSubviews: [prev, separator, button])
hStack.axis = .horizontal
stack.addArrangedSubview(hStack)
} else {
stack.addArrangedSubview(button)
}
}
addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(panRecognized)))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
updateAxis()
super.layoutSubviews()
}
private var menuActionsCount: Int {
var count = 0
for action in reorderedActions {
if case .menu(_) = action.style {
count += 1
}
}
return count
}
private var needsVertical: Bool {
if reorderedActions.count - menuActionsCount > 2 {
return false
}
var requiredWidth: CGFloat = 0
for (index, action) in actionButtons.enumerated() {
if index > 0 {
requiredWidth += 0.5
}
requiredWidth += action.titleView.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize).width
requiredWidth += 8
}
return requiredWidth > bounds.width
}
private func updateAxis() {
if needsVertical {
stack.axis = .vertical
NSLayoutConstraint.deactivate(labelWrapperWidthConstraints)
labelWrapperWidthConstraints = []
NSLayoutConstraint.deactivate(separatorSizeConstraints)
separatorSizeConstraints = separators.map {
$0.heightAnchor.constraint(equalToConstant: 0.5)
}
NSLayoutConstraint.activate(separatorSizeConstraints)
} else {
stack.axis = .horizontal
labelWrapperWidthConstraints = []
var nonMenuAction: CustomAlertActionButton?
for (index, action) in reorderedActions.enumerated() {
if case .menu(_) = action.style {
} else {
if let nonMenuAction {
labelWrapperWidthConstraints.append(
actionButtons[index].widthAnchor.constraint(equalTo: nonMenuAction.widthAnchor)
)
} else {
nonMenuAction = actionButtons[index]
}
}
}
NSLayoutConstraint.activate(labelWrapperWidthConstraints)
NSLayoutConstraint.deactivate(separatorSizeConstraints)
separatorSizeConstraints = separators.map {
$0.widthAnchor.constraint(equalToConstant: 0.5)
}
NSLayoutConstraint.activate(separatorSizeConstraints)
}
}
@objc private func panRecognized(_ recognizer: UIPanGestureRecognizer) {
let selectedButton = actionButtons.enumerated().first {
$0.1.point(inside: recognizer.location(in: $0.1), with: nil)
}
switch recognizer.state {
case .began:
currentSelectedActionIndex = selectedButton?.offset
selectedButton?.element.backgroundColor = .secondarySystemFill
generator.prepare()
case .changed:
if selectedButton == nil && hitTest(recognizer.location(in: self), with: nil)?.tag == ViewTags.customAlertSeparator {
break
}
if selectedButton?.offset != currentSelectedActionIndex {
if let currentSelectedActionIndex {
actionButtons[currentSelectedActionIndex].backgroundColor = nil
}
generator.selectionChanged()
}
currentSelectedActionIndex = selectedButton?.offset
selectedButton?.element.backgroundColor = .secondarySystemFill
generator.prepare()
case .ended:
if let currentSelectedActionIndex {
let button = actionButtons[currentSelectedActionIndex]
button.backgroundColor = nil
let action = reorderedActions[currentSelectedActionIndex]
if action.handler == nil,
case .menu(_) = action.style,
let interaction = button.contextMenuInteraction {
let selector = NSSelectorFromString(["Location:", "At", "Menu", "present", "_"].reversed().joined())
if interaction.responds(to: selector) {
interaction.perform(selector, with: recognizer.location(in: button))
}
} else {
action.handler?()
self.dismiss()
}
}
default:
break
}
}
}
class CustomAlertActionButton: UIControl {
private let action: CustomAlertController.Action
private let dismiss: () -> Void
var titleView = UIStackView()
init(action: CustomAlertController.Action, dismiss: @escaping () -> Void) {
precondition(action.title != nil || action.image != nil, "action must have image and/or title")
self.action = action
self.dismiss = dismiss
super.init(frame: .zero)
titleView = UIStackView()
titleView.axis = .horizontal
titleView.spacing = 4
if let title = action.title {
let label = UILabel()
label.text = title
label.textColor = .tintColor
switch action.style {
case .cancel:
label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0)
case .destructive:
label.textColor = .systemRed
default:
break
}
titleView.addArrangedSubview(label)
}
if let image = action.image {
let imageView = UIImageView(image: image)
titleView.addArrangedSubview(imageView)
}
titleView.translatesAutoresizingMaskIntoConstraints = false
addSubview(titleView)
NSLayoutConstraint.activate([
titleView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 4),
titleView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -4),
titleView.centerXAnchor.constraint(equalTo: centerXAnchor),
titleView.centerYAnchor.constraint(equalTo: centerYAnchor),
heightAnchor.constraint(equalToConstant: 44),
])
if case .menu(_) = action.style {
self.isContextMenuInteractionEnabled = true
self.showsMenuAsPrimaryAction = action.handler == nil
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
guard case .menu(let menuActions) = action.style else {
return nil
}
return UIContextMenuConfiguration(actionProvider: { _ in
return UIMenu(children: menuActions.map { action in
UIAction(title: action.title, subtitle: action.subtitle, image: action.image) { [unowned self] _ in
action.handler()
self.dismiss()
}
})
})
}
override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willDisplayMenuFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) {
super.contextMenuInteraction(interaction, willDisplayMenuFor: configuration, animator: animator)
if let animator {
animator.addAnimations {
self.backgroundColor = nil
}
} else {
backgroundColor = nil
}
}
override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) {
super.contextMenuInteraction(interaction, willEndFor: configuration, animator: animator)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
if point(inside: touches.first!.location(in: self), with: event) {
backgroundColor = .secondarySystemFill
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesMoved(touches, with: event)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
backgroundColor = nil
action.handler?()
dismiss()
}
}
extension CustomAlertController {
override func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return CustomAlertPresentationAnimation()
}
override func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return CustomAlertDismissAnimation()
}
}
class CustomAlertPresentationAnimation: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.2
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let presenter = transitionContext.viewController(forKey: .from),
let alert = transitionContext.viewController(forKey: .to) as? CustomAlertController else {
transitionContext.completeTransition(false)
return
}
let container = transitionContext.containerView
container.addSubview(alert.view)
guard transitionContext.isAnimated else {
presenter.view.tintAdjustmentMode = .dimmed
transitionContext.completeTransition(true)
return
}
alert.dimmingView.layer.opacity = 0
alert.blurView.layer.opacity = 0
alert.blurView.transform = CGAffineTransform(scaleX: 1.1, y: 1.1)
UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: .curveEaseOut) {
presenter.view.tintAdjustmentMode = .dimmed
alert.dimmingView.layer.opacity = 1
alert.blurView.layer.opacity = 1
alert.blurView.transform = .identity
} completion: { _ in
transitionContext.completeTransition(true)
}
}
}
class CustomAlertDismissAnimation: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.2
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let presenter = transitionContext.viewController(forKey: .to),
let alert = transitionContext.viewController(forKey: .from) as? CustomAlertController else {
transitionContext.completeTransition(false)
return
}
guard transitionContext.isAnimated else {
presenter.view.tintAdjustmentMode = .dimmed
transitionContext.completeTransition(true)
return
}
UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0) {
presenter.view.tintAdjustmentMode = .automatic
alert.view.layer.opacity = 0
} completion: { _ in
transitionContext.completeTransition(true)
}
}
}
fileprivate extension UIStackView {
func addSpacer(length: CGFloat) {
let spacer = UIView()
addArrangedSubview(spacer)
if axis == .vertical {
spacer.heightAnchor.constraint(equalToConstant: length).isActive = true
} else {
spacer.widthAnchor.constraint(equalToConstant: length).isActive = true
}
}
}

View File

@ -9,7 +9,14 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable, Item: Hashable>: EnhancedTableViewController, RefreshableViewController { protocol DiffableTimelineLikeSection: Hashable, CaseIterable {
static var loadingIndicator: Self { get }
}
protocol DiffableTimelineLikeItem: Hashable {
static var loadingIndicator: Self { get }
}
class DiffableTimelineLikeTableViewController<Section: DiffableTimelineLikeSection, Item: DiffableTimelineLikeItem>: EnhancedTableViewController, RefreshableViewController {
typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Item> typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Item>
typealias LoadResult = Result<Snapshot, LoadError> typealias LoadResult = Result<Snapshot, LoadError>
@ -40,6 +47,7 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
tableView.rowHeight = UITableView.automaticDimension tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 140 tableView.estimatedRowHeight = 140
tableView.register(LoadingTableViewCell.self, forCellReuseIdentifier: "loadingCell")
#if !targetEnvironment(macCatalyst) #if !targetEnvironment(macCatalyst)
self.refreshControl = UIRefreshControl() self.refreshControl = UIRefreshControl()
@ -104,15 +112,34 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
dataSource.apply(snapshot, animatingDifferences: false) dataSource.apply(snapshot, animatingDifferences: false)
} }
private func showLoadingIndicatorDelayed() -> DispatchWorkItem {
let workItem = DispatchWorkItem { [weak self] in
guard let self = self else { return }
var snapshot = self.dataSource.snapshot()
snapshot.appendSections([.loadingIndicator])
snapshot.appendItems([.loadingIndicator])
self.dataSource.apply(snapshot, animatingDifferences: false)
}
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: workItem)
return workItem
}
private func loadInitial() { private func loadInitial() {
guard state == .unloaded else { return } guard state == .unloaded else { return }
// set loaded immediately so we don't trigger another request while the current one is running // set loaded immediately so we don't trigger another request while the current one is running
state = .loadingInitial state = .loadingInitial
let showIndicator = showLoadingIndicatorDelayed()
loadInitialItems() { result in loadInitialItems() { result in
DispatchQueue.main.async { DispatchQueue.main.async {
showIndicator.cancel()
switch result { switch result {
case let .success(snapshot): case .success(var snapshot):
if snapshot.sectionIdentifiers.contains(.loadingIndicator) {
snapshot.deleteSections([.loadingIndicator])
}
self.dataSource.apply(snapshot, animatingDifferences: false) self.dataSource.apply(snapshot, animatingDifferences: false)
self.state = .loaded self.state = .loaded
@ -137,25 +164,31 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
} }
func loadOlder() { func loadOlder() {
guard state != .loadingOlder else { return } guard state == .loaded else { return }
state = .loadingOlder state = .loadingOlder
let showIndicator = showLoadingIndicatorDelayed()
loadOlderItems(currentSnapshot: dataSource.snapshot) { result in loadOlderItems(currentSnapshot: dataSource.snapshot) { result in
DispatchQueue.main.async { DispatchQueue.main.async {
self.state = .loaded self.state = .loaded
showIndicator.cancel()
switch result { switch result {
case let .success(snapshot): case .success(var snapshot):
if snapshot.sectionIdentifiers.contains(.loadingIndicator) {
snapshot.deleteSections([.loadingIndicator])
}
self.dataSource.apply(snapshot, animatingDifferences: false) self.dataSource.apply(snapshot, animatingDifferences: false)
case let .failure(.client(error)): case let .failure(.client(error)):
let config = ToastConfiguration(from: error, with: "Error Loading Older", in: self) { [weak self] (toast) in let config = ToastConfiguration(from: error, with: "Error Loading Older", in: self) { [weak self] (toast) in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
self?.loadOlder() self?.loadOlder()
} }
self.showToast(configuration: config, animated: true) self.showToast(configuration: config, animated: true)
default: default:
break break
} }
@ -263,6 +296,12 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
// MARK: - Subclass Methods // MARK: - Subclass Methods
func loadingIndicatorCell(indexPath: IndexPath) -> UITableViewCell? {
let cell = tableView.dequeueReusableCell(withIdentifier: "loadingCell", for: indexPath) as! LoadingTableViewCell
cell.indicator.startAnimating()
return cell
}
func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? { func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? {
fatalError("cellProvider(_:_:_:) must be implemented by subclasses") fatalError("cellProvider(_:_:_:) must be implemented by subclasses")
} }

View File

@ -198,7 +198,12 @@ extension MenuActionProvider {
} }
toggleableSection.insert(createAction(identifier: "reblog", title: reblogged ? "Unreblog" : "Reblog", image: reblogImage, handler: { [weak self] _ in toggleableSection.insert(createAction(identifier: "reblog", title: reblogged ? "Unreblog" : "Reblog", image: reblogImage, handler: { [weak self] _ in
guard let self = self else { return } guard let self = self else { return }
let request = (reblogged ? Status.reblog : Status.unreblog)(status.id) let request: Request<Status>
if reblogged {
request = Status.reblog(status.id)
} else {
request = Status.unreblog(status.id)
}
self.mastodonController?.run(request, completion: { response in self.mastodonController?.run(request, completion: { response in
switch response { switch response {
case .success(let status, _): case .success(let status, _):

View File

@ -13,11 +13,13 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
let titles: [String] let titles: [String]
let pageControllers: [UIViewController] let pageControllers: [UIViewController]
private(set) var currentIndex: Int! private(set) var currentIndex = 0
var segmentedControl: UISegmentedControl! var segmentedControl: UISegmentedControl!
init(titles: [String], pageControllers: [UIViewController]) { init(titles: [String], pageControllers: [UIViewController]) {
precondition(!pageControllers.isEmpty)
self.titles = titles self.titles = titles
self.pageControllers = pageControllers self.pageControllers = pageControllers
@ -41,7 +43,6 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
view.backgroundColor = .systemBackground view.backgroundColor = .systemBackground
segmentedControl.selectedSegmentIndex = 0
selectPage(at: 0, animated: false) selectPage(at: 0, animated: false)
addKeyCommand(MenuController.prevSubTabCommand) addKeyCommand(MenuController.prevSubTabCommand)
@ -56,7 +57,7 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
} }
func selectPage(at index: Int, animated: Bool) { func selectPage(at index: Int, animated: Bool) {
let direction: UIPageViewController.NavigationDirection = currentIndex == nil ? .forward : index - currentIndex > 0 ? .forward : .reverse let direction: UIPageViewController.NavigationDirection = index - currentIndex > 0 ? .forward : .reverse
setViewControllers([pageControllers[index]], direction: direction, animated: animated) setViewControllers([pageControllers[index]], direction: direction, animated: animated)
navigationItem.title = pageControllers[index].title navigationItem.title = pageControllers[index].title
currentIndex = index currentIndex = index

View File

@ -67,6 +67,17 @@ class SplitNavigationController: UIViewController {
if let rootViewController { if let rootViewController {
rootNav.viewControllers = [rootViewController] rootNav.viewControllers = [rootViewController]
} }
// add the child VCs here, rather than in viewDidLoad, because this VC is added to the UISplitViewController,
// it needs a UINavigationController to be this VC's first child, otherwise it will embed this VC inside
// yet another UINavigationController, which can then cause a crash when we try to embed a nav controller inside
// of ourself (because nested nav controllers are forbidden)
rootNav.willMove(toParent: self)
addChild(rootNav)
rootNav.didMove(toParent: self)
secondaryNav.willMove(toParent: self)
addChild(secondaryNav)
secondaryNav.didMove(toParent: self)
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -76,10 +87,10 @@ class SplitNavigationController: UIViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
embedChild(rootNav, layout: false)
embedChild(secondaryNav, layout: false)
rootNav.view.translatesAutoresizingMaskIntoConstraints = false rootNav.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(rootNav.view)
secondaryNav.view.translatesAutoresizingMaskIntoConstraints = false secondaryNav.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(secondaryNav.view)
separatorView.backgroundColor = .separator separatorView.backgroundColor = .separator
separatorView.translatesAutoresizingMaskIntoConstraints = false separatorView.translatesAutoresizingMaskIntoConstraints = false

View File

@ -17,4 +17,5 @@ struct ViewTags {
static let navForwardBarButton = 42004 static let navForwardBarButton = 42004
static let navEmptyTitleView = 42005 static let navEmptyTitleView = 42005
static let splitNavCloseSecondaryButton = 42006 static let splitNavCloseSecondaryButton = 42006
static let customAlertSeparator = 42007
} }

View File

@ -12,13 +12,13 @@ import WebURLFoundationExtras
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
struct AccountDisplayNameLabel<Account: AccountProtocol>: View { struct AccountDisplayNameLabel: View {
let account: Account let account: any AccountProtocol
let fontSize: Int let fontSize: Int
@State var text: Text @State var text: Text
@State var emojiRequests = [ImageCache.Request]() @State var emojiRequests = [ImageCache.Request]()
init(account: Account, fontSize: Int) { init(account: any AccountProtocol, fontSize: Int) {
self.account = account self.account = account
self.fontSize = fontSize self.fontSize = fontSize
let name = account.displayName.isEmpty ? account.username : account.displayName let name = account.displayName.isEmpty ? account.username : account.displayName
@ -36,7 +36,7 @@ struct AccountDisplayNameLabel<Account: AccountProtocol>: View {
let matches = emojiRegex.matches(in: account.displayName, options: [], range: fullRange) let matches = emojiRegex.matches(in: account.displayName, options: [], range: fullRange)
guard !matches.isEmpty else { return } guard !matches.isEmpty else { return }
let emojiImages = MultiThreadDictionary<String, Image>(name: "AcccountDisplayNameLabel Emoji Images") let emojiImages = MultiThreadDictionary<String, Image>()
let group = DispatchGroup() let group = DispatchGroup()

View File

@ -24,6 +24,7 @@ extension BaseEmojiLabel {
// blergh // blergh
precondition(Thread.isMainThread) precondition(Thread.isMainThread)
emojiIdentifier = identifier
emojiRequests.forEach { $0.cancel() } emojiRequests.forEach { $0.cancel() }
emojiRequests = [] emojiRequests = []
@ -38,10 +39,7 @@ extension BaseEmojiLabel {
return return
} }
// not using a MultiThreadDictionary so that cached images can be added immediately let emojiImages = MultiThreadDictionary<String, UIImage>()
// without jumping through various queues so that we can use them immediately
// in building either the final string or the string with placeholders
var emojiImages: [String: UIImage] = [:]
var foundEmojis = false var foundEmojis = false
let group = DispatchGroup() let group = DispatchGroup()
@ -71,12 +69,8 @@ extension BaseEmojiLabel {
group.leave() group.leave()
return return
} }
// sync back to the main thread to add the dictionary emojiImages[emoji.shortcode] = transformedImage
// todo: using the main thread for this isn't great group.leave()
DispatchQueue.main.async {
emojiImages[emoji.shortcode] = transformedImage
group.leave()
}
} }
if let request = request { if let request = request {
emojiRequests.append(request) emojiRequests.append(request)
@ -92,21 +86,27 @@ extension BaseEmojiLabel {
func buildStringWithEmojisReplaced(usePlaceholders: Bool) -> NSAttributedString { func buildStringWithEmojisReplaced(usePlaceholders: Bool) -> NSAttributedString {
let mutAttrString = NSMutableAttributedString(attributedString: attributedString) let mutAttrString = NSMutableAttributedString(attributedString: attributedString)
// replaces the emojis starting from the end of the string as to not alter the indices of preceeding emojis // lock once for the entire loop, rather than lock/unlocking for each iteration to do the lookup
for match in matches.reversed() { // OSAllocatedUnfairLock.withLock expects a @Sendable closure, so this warns about captures of non-sendable types (attribute dstrings, text checking results)
let shortcode = (attributedString.string as NSString).substring(with: match.range(at: 1)) // even though the closures is invoked on the same thread that withLock is called, so it's unclear why it needs to be @Sendable (FB11494878)
let attachment: NSTextAttachment // so, just ignore the warnings
emojiImages.withLock { emojiImages in
if let emojiImage = emojiImages[shortcode] { // replaces the emojis starting from the end of the string as to not alter the indices of preceeding emojis
attachment = NSTextAttachment(emojiImage: emojiImage, in: self.emojiFont, with: self.emojiTextColor) for match in matches.reversed() {
} else if usePlaceholders { let shortcode = (attributedString.string as NSString).substring(with: match.range(at: 1))
attachment = NSTextAttachment(emojiPlaceholderIn: self.emojiFont) let attachment: NSTextAttachment
} else {
continue if let emojiImage = emojiImages[shortcode] {
attachment = NSTextAttachment(emojiImage: emojiImage, in: self.emojiFont, with: self.emojiTextColor)
} else if usePlaceholders {
attachment = NSTextAttachment(emojiPlaceholderIn: self.emojiFont)
} else {
continue
}
let attachmentStr = NSAttributedString(attachment: attachment)
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
} }
let attachmentStr = NSAttributedString(attachment: attachment)
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
} }
return mutAttrString return mutAttrString

View File

@ -0,0 +1,86 @@
//
// ConfirmReblogStatusPreviewView.swift
// Tusker
//
// Created by Shadowfacts on 9/17/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
class ConfirmReblogStatusPreviewView: UIView {
private var avatarTask: Task<Void, Error>?
init(status: StatusMO) {
super.init(frame: .zero)
let hStack = UIStackView()
hStack.axis = .horizontal
hStack.spacing = 8
hStack.alignment = .leading
hStack.translatesAutoresizingMaskIntoConstraints = false
addSubview(hStack)
NSLayoutConstraint.activate([
hStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
hStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
hStack.topAnchor.constraint(equalTo: topAnchor),
hStack.bottomAnchor.constraint(equalTo: bottomAnchor),
])
let avatarSize: CGFloat = 30
let avatarImageView = UIImageView()
avatarImageView.layer.masksToBounds = true
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * avatarSize
hStack.addArrangedSubview(avatarImageView)
NSLayoutConstraint.activate([
avatarImageView.widthAnchor.constraint(equalToConstant: avatarSize),
avatarImageView.heightAnchor.constraint(equalToConstant: avatarSize),
])
if let avatar = status.account.avatar {
avatarTask = Task {
let (_, image) = await ImageCache.avatars.get(avatar)
try Task.checkCancellation()
avatarImageView.image = image
}
}
let vStack = UIStackView()
vStack.axis = .vertical
vStack.spacing = 2
vStack.alignment = .fill
vStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
hStack.addArrangedSubview(vStack)
let displayNameLabel = EmojiLabel()
displayNameLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .caption1).addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold]]), size: 0)
displayNameLabel.adjustsFontSizeToFitWidth = true
displayNameLabel.updateForAccountDisplayName(account: status.account)
vStack.addArrangedSubview(displayNameLabel)
let contentView = StatusContentTextView()
contentView.defaultFont = .preferredFont(forTextStyle: .caption2)
contentView.isUserInteractionEnabled = false
contentView.isScrollEnabled = false
contentView.backgroundColor = nil
contentView.textContainerInset = .zero
// remove the extra line spacing applied by StatusContentTextView because, since we're using a smaller font, the regular 2pt looks big
contentView.paragraphStyle = .default
// TODO: line limit
contentView.setTextFrom(status: status)
contentView.translatesAutoresizingMaskIntoConstraints = false
vStack.addArrangedSubview(contentView)
NSLayoutConstraint.activate([
contentView.heightAnchor.constraint(lessThanOrEqualToConstant: 200),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
avatarTask?.cancel()
}
}

View File

@ -23,6 +23,12 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
var defaultFont: UIFont = .systemFont(ofSize: 17) var defaultFont: UIFont = .systemFont(ofSize: 17)
var defaultColor: UIColor = .label var defaultColor: UIColor = .label
var paragraphStyle: NSParagraphStyle = {
let style = NSMutableParagraphStyle()
// 2 points is enough that it doesn't make things look weirdly spaced out, but leaves a slight gap when there are multiple lines of emojis
style.lineSpacing = 2
return style
}()
private(set) var hasEmojis = false private(set) var hasEmojis = false
@ -36,9 +42,17 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
// The preview created in the previewForHighlighting method, so that we can use the same one in previewForDismissing. // The preview created in the previewForHighlighting method, so that we can use the same one in previewForDismissing.
private weak var currentTargetedPreview: UITargetedPreview? private weak var currentTargetedPreview: UITargetedPreview?
override func awakeFromNib() { override init(frame: CGRect, textContainer: NSTextContainer?) {
super.awakeFromNib() super.init(frame: frame, textContainer: textContainer)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
delegate = self delegate = self
// Disable layer masking, otherwise the context menu opening animation // Disable layer masking, otherwise the context menu opening animation
@ -57,8 +71,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
} }
// MARK: - Emojis // MARK: - Emojis
func setEmojis(_ emojis: [Emoji]) { func setEmojis(_ emojis: [Emoji], identifier: String?) {
replaceEmojis(in: attributedText!, emojis: emojis, identifier: emojiIdentifier) { attributedString, didReplaceEmojis in replaceEmojis(in: attributedText!, emojis: emojis, identifier: identifier) { attributedString, didReplaceEmojis in
guard didReplaceEmojis else { guard didReplaceEmojis else {
return return
} }
@ -78,10 +92,7 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines) mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines)
mutAttrString.collapseWhitespace() mutAttrString.collapseWhitespace()
let style = NSMutableParagraphStyle() mutAttrString.addAttribute(.paragraphStyle, value: paragraphStyle, range: mutAttrString.fullRange)
// 2 points is enough that it doesn't make things look weirdly spaced out, but leaves a slight gap when there are multiple lines of emojis
style.lineSpacing = 2
mutAttrString.addAttribute(.paragraphStyle, value: style, range: mutAttrString.fullRange)
self.attributedText = mutAttrString self.attributedText = mutAttrString
} }
@ -197,13 +208,17 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
let locationInTextContainer = CGPoint(x: point.x - textContainerInset.left, y: point.y - textContainerInset.top) let locationInTextContainer = CGPoint(x: point.x - textContainerInset.left, y: point.y - textContainerInset.top)
if #available(iOS 16.0, *), if #available(iOS 16.0, *),
let textLayoutManager { let textLayoutManager {
guard let fragment = textLayoutManager.textLayoutFragment(for: point), guard let fragment = textLayoutManager.textLayoutFragment(for: locationInTextContainer) else {
let lineFragment = fragment.textLineFragments.first(where: { lineFragment in return nil
lineFragment.typographicBounds.offsetBy(dx: fragment.layoutFragmentFrame.minX, dy: fragment.layoutFragmentFrame.minY).contains(point) }
let pointInLayoutFragment = CGPoint(x: locationInTextContainer.x - fragment.layoutFragmentFrame.minX, y: locationInTextContainer.y - fragment.layoutFragmentFrame.minY)
guard let lineFragment = fragment.textLineFragments.first(where: { lineFragment in
lineFragment.typographicBounds.contains(pointInLayoutFragment)
}) else { }) else {
return nil return nil
} }
let charIndex = lineFragment.characterIndex(for: point) let pointInLineFragment = CGPoint(x: pointInLayoutFragment.x - lineFragment.typographicBounds.minX, y: pointInLayoutFragment.y - lineFragment.typographicBounds.minY)
let charIndex = lineFragment.characterIndex(for: pointInLineFragment)
var range = NSRange() var range = NSRange()
guard let link = lineFragment.attributedString.attribute(.link, at: charIndex, longestEffectiveRange: &range, in: lineFragment.attributedString.fullRange) as? URL else { guard let link = lineFragment.attributedString.attribute(.link, at: charIndex, longestEffectiveRange: &range, in: lineFragment.attributedString.fullRange) as? URL else {
@ -332,7 +347,7 @@ extension ContentTextView: UIContextMenuInteractionDelegate {
return nil return nil
} }
// .standard because i have no idea what the difference is // .standard because i have no idea what the difference is
textLayoutManager.enumerateTextSegments(in: textRange, type: .standard, options: []) { range, rect, float, textContainer in textLayoutManager.enumerateTextSegments(in: textRange, type: .standard, options: .rangeNotRequired) { range, rect, float, textContainer in
rects.append(rect) rects.append(rect)
return true return true
} }

View File

@ -21,13 +21,9 @@ class EmojiLabel: UILabel, BaseEmojiLabel {
func setEmojis(_ emojis: [Emoji], identifier: String) { func setEmojis(_ emojis: [Emoji], identifier: String) {
guard emojis.count > 0, let attributedText = attributedText else { return } guard emojis.count > 0, let attributedText = attributedText else { return }
self.emojiIdentifier = identifier replaceEmojis(in: attributedText.string, emojis: emojis, identifier: identifier) { [weak self] (newAttributedText, didReplaceEmojis) in
emojiRequests.forEach { $0.cancel() }
emojiRequests = []
hasEmojis = true
replaceEmojis(in: attributedText.string, emojis: emojis, identifier: identifier) { [weak self] (newAttributedText, _) in
guard let self = self, self.emojiIdentifier == identifier else { return } guard let self = self, self.emojiIdentifier == identifier else { return }
self.hasEmojis = didReplaceEmojis
self.attributedText = newAttributedText self.attributedText = newAttributedText
self.setNeedsLayout() self.setNeedsLayout()
self.setNeedsDisplay() self.setNeedsDisplay()

View File

@ -0,0 +1,29 @@
//
// LoadingTableViewCell.swift
// Tusker
//
// Created by Shadowfacts on 9/12/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
class LoadingTableViewCell: UITableViewCell {
let indicator = UIActivityIndicatorView(style: .medium)
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
indicator.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(indicator)
NSLayoutConstraint.activate([
indicator.centerXAnchor.constraint(equalTo: centerXAnchor),
indicator.topAnchor.constraint(equalTo: topAnchor),
indicator.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -110,7 +110,7 @@ class ProfileHeaderView: UIView {
noteTextView.navigationDelegate = delegate noteTextView.navigationDelegate = delegate
noteTextView.setTextFromHtml(account.note) noteTextView.setTextFromHtml(account.note)
noteTextView.setEmojis(account.emojis) noteTextView.setEmojis(account.emojis, identifier: account.id)
// don't show relationship label for the user's own account // don't show relationship label for the user's own account
if accountID != mastodonController.account?.id { if accountID != mastodonController.account?.id {
@ -148,7 +148,7 @@ class ProfileHeaderView: UIView {
valueTextView.isSelectable = false valueTextView.isSelectable = false
valueTextView.font = .systemFont(ofSize: 17) valueTextView.font = .systemFont(ofSize: 17)
valueTextView.setTextFromHtml(field.value) valueTextView.setTextFromHtml(field.value)
valueTextView.setEmojis(account.emojis) valueTextView.setEmojis(account.emojis, identifier: account.id)
valueTextView.textAlignment = .left valueTextView.textAlignment = .left
valueTextView.awakeFromNib() valueTextView.awakeFromNib()
valueTextView.navigationDelegate = delegate valueTextView.navigationDelegate = delegate

View File

@ -412,25 +412,52 @@ class BaseStatusTableViewCell: UITableViewCell {
// if we are about to reblog and the user has confirmation enabled // if we are about to reblog and the user has confirmation enabled
if !reblogged, if !reblogged,
Preferences.shared.confirmBeforeReblog { Preferences.shared.confirmBeforeReblog {
let alert = UIAlertController(title: "Confirm Reblog", message: "Are you sure you want to reblog this post by @\(status.account.acct)?", preferredStyle: .alert) let image: UIImage?
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) let reblogVisibilityActions: [CustomAlertController.MenuAction]?
alert.addAction(UIAlertAction(title: "Reblog", style: .default) { (_) in if mastodonController.instanceFeatures.reblogVisibility {
self.toggleReblogInternal() image = UIImage(systemName: Status.Visibility.public.unfilledImageName)
}) reblogVisibilityActions = [Status.Visibility.unlisted, .private].map { visibility in
CustomAlertController.MenuAction(title: "Reblog as \(visibility.displayName)", subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName)) { [unowned self] in
self.toggleReblogInternal(visibility: visibility)
}
}
} else {
image = nil
reblogVisibilityActions = nil
}
let preview = ConfirmReblogStatusPreviewView(status: status)
var config = CustomAlertController.Configuration(title: "Are you sure you want to reblog this post?", content: preview, actions: [
CustomAlertController.Action(title: "Cancel", style: .cancel, handler: nil),
CustomAlertController.Action(title: "Reblog", image: image, style: .default, handler: { [unowned self] in
self.toggleReblogInternal(visibility: nil)
}),
])
if let reblogVisibilityActions {
var menuAction = CustomAlertController.Action(title: nil, image: UIImage(systemName: "chevron.down"), style: .menu(reblogVisibilityActions), handler: nil)
menuAction.isSecondaryMenu = true
config.actions.append(menuAction)
}
let alert = CustomAlertController(config: config)
delegate?.present(alert, animated: true) delegate?.present(alert, animated: true)
} else { } else {
toggleReblogInternal() toggleReblogInternal(visibility: nil)
} }
} }
private func toggleReblogInternal() { private func toggleReblogInternal(visibility: Status.Visibility?) {
guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") } guard let status = mastodonController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") }
let oldValue = reblogged let oldValue = reblogged
reblogged = !reblogged reblogged = !reblogged
let realStatus = status.reblog ?? status let realStatus = status.reblog ?? status
let request = (reblogged ? Status.reblog : Status.unreblog)(realStatus.id) let request: Request<Status>
if reblogged {
request = Status.reblog(realStatus.id, visibility: visibility)
} else {
request = Status.unreblog(realStatus.id)
}
mastodonController.run(request) { response in mastodonController.run(request) { response in
DispatchQueue.main.async { DispatchQueue.main.async {
if case let .success(newStatus, _) = response { if case let .success(newStatus, _) = response {

View File

@ -20,6 +20,7 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
}() }()
@IBOutlet weak var reblogLabel: EmojiLabel! @IBOutlet weak var reblogLabel: EmojiLabel!
@IBOutlet weak var reblogSpacer: UIView!
@IBOutlet weak var timestampLabel: UILabel! @IBOutlet weak var timestampLabel: UILabel!
@IBOutlet weak var pinImageView: UIImageView! @IBOutlet weak var pinImageView: UIImageView!
@IBOutlet weak var actionsContainerView: UIView! @IBOutlet weak var actionsContainerView: UIView!
@ -82,6 +83,7 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
reblogStatusID = statusID reblogStatusID = statusID
rebloggerID = status.account.id rebloggerID = status.account.id
reblogLabel.isHidden = false reblogLabel.isHidden = false
reblogSpacer.isHidden = false
updateRebloggerLabel(reblogger: status.account) updateRebloggerLabel(reblogger: status.account)
status = rebloggedStatus status = rebloggedStatus
@ -91,6 +93,7 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
reblogStatusID = nil reblogStatusID = nil
rebloggerID = nil rebloggerID = nil
reblogLabel.isHidden = true reblogLabel.isHidden = true
reblogSpacer.isHidden = true
} }
super.doUpdateUI(status: status, state: state) super.doUpdateUI(status: status, state: state)
@ -131,8 +134,12 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
let oldState = actionsContainerView.isHidden let oldState = actionsContainerView.isHidden
if oldState != Preferences.shared.hideActionsInTimeline { if oldState != Preferences.shared.hideActionsInTimeline {
updateActionsVisibility() updateActionsVisibility()
// not really accurate, but it notifies the vc our height has changed if #available(iOS 16.0, *) {
delegate?.statusCellCollapsedStateChanged(self) invalidateIntrinsicContentSize()
} else {
// not really accurate, but it notifies the vc our height has changed
delegate?.statusCellCollapsedStateChanged(self)
}
} }
super.updateStatusIconsForPreferences(status) super.updateStatusIconsForPreferences(status)
@ -141,16 +148,8 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
private func updateActionsVisibility() { private func updateActionsVisibility() {
if Preferences.shared.hideActionsInTimeline { if Preferences.shared.hideActionsInTimeline {
actionsContainerView.isHidden = true actionsContainerView.isHidden = true
actionsContainerHeightConstraint.isActive = false
verticalStackToSuperviewConstraint.isActive = true
verticalStackToActionsContainerConstraint.isActive = false
} else { } else {
actionsContainerView.isHidden = false actionsContainerView.isHidden = false
// sometimes this constraint is nil for reasons i can't discern
// not re-activating in that case doesn't seem to make a difference
actionsContainerHeightConstraint?.isActive = true
verticalStackToSuperviewConstraint.isActive = false
verticalStackToActionsContainerConstraint.isActive = true
} }
} }

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21179.7" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21225" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/> <device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21169.4"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21207"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -15,17 +15,24 @@
<rect key="frame" x="0.0" y="0.0" width="375" height="240"/> <rect key="frame" x="0.0" y="0.0" width="375" height="240"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews> <subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="yNh-ac-v6c"> <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="yNh-ac-v6c">
<rect key="frame" x="16" y="8" width="343" height="224"/> <rect key="frame" x="16" y="8" width="343" height="224"/>
<subviews> <subviews>
<label opaque="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" verticalCompressionResistancePriority="751" text="Reblogged by Person" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="lDH-50-AJZ" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target"> <label opaque="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" verticalCompressionResistancePriority="751" text="Reblogged by Person" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="lDH-50-AJZ" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="162.5" height="20.5"/> <rect key="frame" x="0.0" y="0.0" width="343" height="20.5"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/> <fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/> <color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/> <nil key="highlightedColor"/>
</label> </label>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="H6C-5s-ICE">
<rect key="frame" x="0.0" y="20.5" width="343" height="4"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" constant="4" id="KdU-GV-9et"/>
</constraints>
</view>
<view contentMode="scaleToFill" verticalHuggingPriority="249" translatesAutoresizingMaskIntoConstraints="NO" id="ve3-Y1-NQH"> <view contentMode="scaleToFill" verticalHuggingPriority="249" translatesAutoresizingMaskIntoConstraints="NO" id="ve3-Y1-NQH">
<rect key="frame" x="0.0" y="28.5" width="343" height="195.5"/> <rect key="frame" x="0.0" y="24.5" width="343" height="173.5"/>
<subviews> <subviews>
<imageView contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="QMP-j2-HLn"> <imageView contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="QMP-j2-HLn">
<rect key="frame" x="0.0" y="0.0" width="50" height="50"/> <rect key="frame" x="0.0" y="0.0" width="50" height="50"/>
@ -109,36 +116,30 @@
</connections> </connections>
</button> </button>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" delaysContentTouches="NO" editable="NO" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="waJ-f5-LKv" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target"> <textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" delaysContentTouches="NO" editable="NO" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="waJ-f5-LKv" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="83" width="277" height="82.5"/> <rect key="frame" x="0.0" y="83" width="277" height="86.5"/>
<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> <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"/> <color key="textColor" systemColor="labelColor"/>
<fontDescription key="fontDescription" type="system" pointSize="16"/> <fontDescription key="fontDescription" type="system" pointSize="16"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/> <textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView> </textView>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="LKo-VB-XWl" customClass="StatusCardView" customModule="Tusker" customModuleProvider="target"> <view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="LKo-VB-XWl" customClass="StatusCardView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="167.5" width="277" height="0.0"/> <rect key="frame" x="0.0" y="171.5" width="277" height="0.0"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/> <color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints> <constraints>
<constraint firstAttribute="height" priority="999" constant="65" id="khY-jm-CPn"/> <constraint firstAttribute="height" priority="999" constant="65" id="khY-jm-CPn"/>
</constraints> </constraints>
</view> </view>
<view hidden="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="nbq-yr-2mA" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target"> <view hidden="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="nbq-yr-2mA" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="167.5" width="277" height="0.0"/> <rect key="frame" x="0.0" y="171.5" width="277" height="0.0"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/> <color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
<constraints> <constraints>
<constraint firstAttribute="height" secondItem="nbq-yr-2mA" secondAttribute="width" multiplier="9:16" priority="999" id="Rvt-zs-fkd"/> <constraint firstAttribute="height" secondItem="nbq-yr-2mA" secondAttribute="width" multiplier="9:16" priority="999" id="Rvt-zs-fkd"/>
</constraints> </constraints>
</view> </view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="x3b-Zl-9F0" customClass="StatusPollView" customModule="Tusker" customModuleProvider="target"> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="x3b-Zl-9F0" customClass="StatusPollView" 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="173.5" width="277" height="0.0"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/> <color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view> </view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="1" verticalCompressionResistancePriority="1" text="" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="oFl-rC-EEN">
<rect key="frame" x="0.0" y="173.5" width="277" height="0.0"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews> </subviews>
</stackView> </stackView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="qBn-Gk-DCa" customClass="StatusMetaIndicatorsView" customModule="Tusker" customModuleProvider="target"> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="qBn-Gk-DCa" customClass="StatusMetaIndicatorsView" customModule="Tusker" customModuleProvider="target">
@ -148,78 +149,18 @@
<constraint firstAttribute="height" constant="22" placeholder="YES" id="ipd-WE-P20"/> <constraint firstAttribute="height" constant="22" placeholder="YES" id="ipd-WE-P20"/>
</constraints> </constraints>
</view> </view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="TUP-Nz-5Yh">
<rect key="frame" x="0.0" y="169.5" width="335" height="26"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="rKF-yF-KIa">
<rect key="frame" x="0.0" y="0.0" width="84" height="26"/>
<accessibility key="accessibilityConfiguration" label="Reply"/>
<fontDescription key="fontDescription" type="system" pointSize="18"/>
<state key="normal" image="arrowshape.turn.up.left.fill" catalog="system"/>
<connections>
<action selector="replyPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="ybz-3W-jAa"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="6tW-z8-Qh9">
<rect key="frame" x="167.5" y="0.0" width="84" height="26"/>
<accessibility key="accessibilityConfiguration" label="Reblog"/>
<state key="normal" image="repeat" catalog="system"/>
<connections>
<action selector="reblogPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="Wa2-ZA-TBo"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="982-J4-NGl">
<rect key="frame" x="251.5" y="0.0" width="83.5" height="26"/>
<accessibility key="accessibilityConfiguration" label="More Actions"/>
<state key="normal" image="ellipsis" catalog="system"/>
<connections>
<action selector="morePressed" destination="iN0-l3-epB" eventType="touchUpInside" id="WT4-fi-usq"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="x0t-TR-jJ4">
<rect key="frame" x="84" y="0.0" width="83.5" height="26"/>
<accessibility key="accessibilityConfiguration" label="Favorite"/>
<state key="normal" image="star.fill" catalog="system"/>
<connections>
<action selector="favoritePressed" destination="iN0-l3-epB" eventType="touchUpInside" id="8Q8-Rz-k02"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstAttribute="height" constant="26" id="1FK-Er-G11"/>
<constraint firstAttribute="bottom" secondItem="rKF-yF-KIa" secondAttribute="bottom" id="KyG-2C-MgN"/>
<constraint firstItem="x0t-TR-jJ4" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="L3w-JH-eeG"/>
<constraint firstItem="6tW-z8-Qh9" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="N7j-f4-gvP"/>
<constraint firstItem="982-J4-NGl" firstAttribute="leading" secondItem="6tW-z8-Qh9" secondAttribute="trailing" id="VQo-DJ-C7L"/>
<constraint firstItem="982-J4-NGl" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="W53-1a-fKu"/>
<constraint firstItem="x0t-TR-jJ4" firstAttribute="leading" secondItem="rKF-yF-KIa" secondAttribute="trailing" id="WPd-A2-6Ju"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="width" secondItem="x0t-TR-jJ4" secondAttribute="width" id="X7m-pJ-oje"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="leading" secondItem="TUP-Nz-5Yh" secondAttribute="leading" placeholder="YES" id="aFR-Ew-99S"/>
<constraint firstAttribute="bottom" secondItem="982-J4-NGl" secondAttribute="bottom" id="eXy-3h-51w"/>
<constraint firstAttribute="bottom" secondItem="x0t-TR-jJ4" secondAttribute="bottom" id="euN-Nf-rwh"/>
<constraint firstItem="6tW-z8-Qh9" firstAttribute="leading" secondItem="x0t-TR-jJ4" secondAttribute="trailing" id="oAK-VG-bbp"/>
<constraint firstAttribute="bottom" secondItem="6tW-z8-Qh9" secondAttribute="bottom" id="tpf-Q3-V3l"/>
<constraint firstAttribute="trailing" secondItem="982-J4-NGl" secondAttribute="trailing" id="uQG-FZ-F7u"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="width" secondItem="982-J4-NGl" secondAttribute="width" id="vir-iq-biv"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="width" secondItem="6tW-z8-Qh9" secondAttribute="width" id="vqw-d7-VtZ"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="wWH-J7-egM"/>
</constraints>
</view>
</subviews> </subviews>
<constraints> <constraints>
<constraint firstItem="gIY-Wp-RSk" firstAttribute="leading" secondItem="QMP-j2-HLn" secondAttribute="trailing" constant="8" id="0Tm-v7-Ts4"/> <constraint firstItem="gIY-Wp-RSk" firstAttribute="leading" secondItem="QMP-j2-HLn" secondAttribute="trailing" constant="8" id="0Tm-v7-Ts4"/>
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="QMP-j2-HLn" secondAttribute="bottom" constant="8" id="2Ao-Gj-fY3"/> <constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="QMP-j2-HLn" secondAttribute="bottom" constant="8" id="2Ao-Gj-fY3"/>
<constraint firstItem="TUP-Nz-5Yh" firstAttribute="trailing" secondItem="ve3-Y1-NQH" secondAttribute="trailingMargin" id="3l0-tE-Ak1"/> <constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="gIY-Wp-RSk" secondAttribute="bottom" id="6OU-Ub-VH8"/>
<constraint firstItem="TUP-Nz-5Yh" firstAttribute="top" secondItem="gIY-Wp-RSk" secondAttribute="bottom" constant="-4" id="4KL-a3-qyf"/>
<constraint firstItem="gIY-Wp-RSk" firstAttribute="leading" secondItem="qBn-Gk-DCa" secondAttribute="trailing" constant="8" id="AQs-QN-j49"/> <constraint firstItem="gIY-Wp-RSk" firstAttribute="leading" secondItem="qBn-Gk-DCa" secondAttribute="trailing" constant="8" id="AQs-QN-j49"/>
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="qBn-Gk-DCa" secondAttribute="bottom" id="P1i-ZM-TRt"/>
<constraint firstItem="QMP-j2-HLn" firstAttribute="top" secondItem="ve3-Y1-NQH" secondAttribute="top" id="PC4-Bi-QXm"/> <constraint firstItem="QMP-j2-HLn" firstAttribute="top" secondItem="ve3-Y1-NQH" secondAttribute="top" id="PC4-Bi-QXm"/>
<constraint firstItem="TUP-Nz-5Yh" firstAttribute="leading" secondItem="ve3-Y1-NQH" secondAttribute="leading" id="QZ2-iO-ckC"/>
<constraint firstItem="gIY-Wp-RSk" firstAttribute="top" secondItem="QMP-j2-HLn" secondAttribute="top" id="fEd-wN-kuQ"/> <constraint firstItem="gIY-Wp-RSk" firstAttribute="top" secondItem="QMP-j2-HLn" secondAttribute="top" id="fEd-wN-kuQ"/>
<constraint firstItem="TUP-Nz-5Yh" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="qBn-Gk-DCa" secondAttribute="bottom" id="gxb-hp-7lU"/>
<constraint firstAttribute="trailingMargin" secondItem="gIY-Wp-RSk" secondAttribute="trailing" id="hKk-kO-wFT"/> <constraint firstAttribute="trailingMargin" secondItem="gIY-Wp-RSk" secondAttribute="trailing" id="hKk-kO-wFT"/>
<constraint firstItem="qBn-Gk-DCa" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="ve3-Y1-NQH" secondAttribute="leading" id="iLD-VU-ixJ"/> <constraint firstItem="qBn-Gk-DCa" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="ve3-Y1-NQH" secondAttribute="leading" id="iLD-VU-ixJ"/>
<constraint firstAttribute="bottom" secondItem="gIY-Wp-RSk" secondAttribute="bottom" id="kq7-bk-S8j"/> <constraint firstAttribute="bottom" secondItem="gIY-Wp-RSk" secondAttribute="bottom" id="kq7-bk-S8j"/>
<constraint firstAttribute="bottom" secondItem="TUP-Nz-5Yh" secondAttribute="bottom" id="rmQ-QM-Llu"/>
<constraint firstItem="qBn-Gk-DCa" firstAttribute="top" secondItem="QMP-j2-HLn" secondAttribute="bottom" constant="4" id="tKU-VP-n8P"/> <constraint firstItem="qBn-Gk-DCa" firstAttribute="top" secondItem="QMP-j2-HLn" secondAttribute="bottom" constant="4" id="tKU-VP-n8P"/>
<constraint firstItem="qBn-Gk-DCa" firstAttribute="width" secondItem="QMP-j2-HLn" secondAttribute="width" id="v1v-Pp-ubE"/> <constraint firstItem="qBn-Gk-DCa" firstAttribute="width" secondItem="QMP-j2-HLn" secondAttribute="width" id="v1v-Pp-ubE"/>
<constraint firstItem="QMP-j2-HLn" firstAttribute="leading" secondItem="ve3-Y1-NQH" secondAttribute="leading" id="zeW-tQ-uJl"/> <constraint firstItem="QMP-j2-HLn" firstAttribute="leading" secondItem="ve3-Y1-NQH" secondAttribute="leading" id="zeW-tQ-uJl"/>
@ -230,6 +171,63 @@
</mask> </mask>
</variation> </variation>
</view> </view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="TUP-Nz-5Yh">
<rect key="frame" x="0.0" y="198" 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="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>
<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>
</subviews>
<constraints>
<constraint firstAttribute="height" constant="26" id="1FK-Er-G11"/>
<constraint firstAttribute="bottom" secondItem="rKF-yF-KIa" secondAttribute="bottom" id="KyG-2C-MgN"/>
<constraint firstItem="x0t-TR-jJ4" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="L3w-JH-eeG"/>
<constraint firstItem="6tW-z8-Qh9" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="N7j-f4-gvP"/>
<constraint firstItem="982-J4-NGl" firstAttribute="leading" secondItem="6tW-z8-Qh9" secondAttribute="trailing" id="VQo-DJ-C7L"/>
<constraint firstItem="982-J4-NGl" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="W53-1a-fKu"/>
<constraint firstItem="x0t-TR-jJ4" firstAttribute="leading" secondItem="rKF-yF-KIa" secondAttribute="trailing" id="WPd-A2-6Ju"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="width" secondItem="x0t-TR-jJ4" secondAttribute="width" id="X7m-pJ-oje"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="leading" secondItem="TUP-Nz-5Yh" secondAttribute="leading" placeholder="YES" id="aFR-Ew-99S"/>
<constraint firstAttribute="bottom" secondItem="982-J4-NGl" secondAttribute="bottom" id="eXy-3h-51w"/>
<constraint firstAttribute="bottom" secondItem="x0t-TR-jJ4" secondAttribute="bottom" id="euN-Nf-rwh"/>
<constraint firstItem="6tW-z8-Qh9" firstAttribute="leading" secondItem="x0t-TR-jJ4" secondAttribute="trailing" id="oAK-VG-bbp"/>
<constraint firstAttribute="bottom" secondItem="6tW-z8-Qh9" secondAttribute="bottom" id="tpf-Q3-V3l"/>
<constraint firstAttribute="trailing" secondItem="982-J4-NGl" secondAttribute="trailing" id="uQG-FZ-F7u"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="width" secondItem="982-J4-NGl" secondAttribute="width" id="vir-iq-biv"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="width" secondItem="6tW-z8-Qh9" secondAttribute="width" id="vqw-d7-VtZ"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="wWH-J7-egM"/>
</constraints>
</view>
</subviews> </subviews>
<constraints> <constraints>
<constraint firstItem="ve3-Y1-NQH" firstAttribute="width" secondItem="yNh-ac-v6c" secondAttribute="width" id="xN6-cs-Tnn"/> <constraint firstItem="ve3-Y1-NQH" firstAttribute="width" secondItem="yNh-ac-v6c" secondAttribute="width" id="xN6-cs-Tnn"/>
@ -262,10 +260,10 @@
<outlet property="pollView" destination="x3b-Zl-9F0" id="WIF-Oz-cnm"/> <outlet property="pollView" destination="x3b-Zl-9F0" id="WIF-Oz-cnm"/>
<outlet property="reblogButton" destination="6tW-z8-Qh9" id="u2t-8D-kOn"/> <outlet property="reblogButton" destination="6tW-z8-Qh9" id="u2t-8D-kOn"/>
<outlet property="reblogLabel" destination="lDH-50-AJZ" id="uJf-Pt-cEP"/> <outlet property="reblogLabel" destination="lDH-50-AJZ" id="uJf-Pt-cEP"/>
<outlet property="reblogSpacer" destination="H6C-5s-ICE" id="yFb-Yx-flv"/>
<outlet property="replyButton" destination="rKF-yF-KIa" id="rka-q1-o4a"/> <outlet property="replyButton" destination="rKF-yF-KIa" id="rka-q1-o4a"/>
<outlet property="timestampLabel" destination="35d-EA-ReR" id="Ny2-nV-nqP"/> <outlet property="timestampLabel" destination="35d-EA-ReR" id="Ny2-nV-nqP"/>
<outlet property="usernameLabel" destination="j89-zc-SFa" id="bXX-FZ-fCp"/> <outlet property="usernameLabel" destination="j89-zc-SFa" id="bXX-FZ-fCp"/>
<outlet property="verticalStackToActionsContainerConstraint" destination="4KL-a3-qyf" id="ooW-DI-8AX"/>
<outlet property="verticalStackToSuperviewConstraint" destination="kq7-bk-S8j" id="Rfv-Bn-8B4"/> <outlet property="verticalStackToSuperviewConstraint" destination="kq7-bk-S8j" id="Rfv-Bn-8B4"/>
</connections> </connections>
<point key="canvasLocation" x="29.600000000000001" y="79.160419790104953"/> <point key="canvasLocation" x="29.600000000000001" y="79.160419790104953"/>

View File

@ -16,9 +16,8 @@ class StatusContentTextView: ContentTextView {
func setTextFrom(status: StatusMO) { func setTextFrom(status: StatusMO) {
statusID = status.id statusID = status.id
emojiIdentifier = status.id
setTextFromHtml(status.content) setTextFromHtml(status.content)
setEmojis(status.emojis) setEmojis(status.emojis, identifier: status.id)
} }
override func getMention(for url: URL, text: String) -> Mention? { override func getMention(for url: URL, text: String) -> Mention? {

View File

@ -202,7 +202,7 @@ struct XCBActions {
} }
static func reblogStatus(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) { static func reblogStatus(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
statusAction(request: Status.reblog, alertTitle: "Reblog status?", request, session, silent) statusAction(request: { Status.reblog($0) }, alertTitle: "Reblog status?", request, session, silent)
} }
static func statusAction(request: @escaping (String) -> Request<Status>, alertTitle: String, _ url: XCBRequest, _ session: XCBSession, _ silent: Bool?) { static func statusAction(request: @escaping (String) -> Request<Status>, alertTitle: String, _ url: XCBRequest, _ session: XCBSession, _ silent: Bool?) {

View File

@ -5,7 +5,7 @@
<key>poll votes count</key> <key>poll votes count</key>
<dict> <dict>
<key>NSStringLocalizedFormatKey</key> <key>NSStringLocalizedFormatKey</key>
<string>%2$#@votes@</string> <string>%#@votes@</string>
<key>votes</key> <key>votes</key>
<dict> <dict>
<key>NSStringFormatSpecTypeKey</key> <key>NSStringFormatSpecTypeKey</key>