Compare commits
25 Commits
5e7a1e5974
...
123a512d3c
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 123a512d3c | |
Shadowfacts | d141ed7d03 | |
Shadowfacts | 95e120afd6 | |
Shadowfacts | ca8a214cf6 | |
Shadowfacts | 7161861d36 | |
Shadowfacts | c6c8f63e39 | |
Shadowfacts | e9962997a6 | |
Shadowfacts | f2ab1778c5 | |
Shadowfacts | 0f71d61b88 | |
Shadowfacts | 80c4fcce82 | |
Shadowfacts | 8f8d50efbd | |
Shadowfacts | 43b4976ed7 | |
Shadowfacts | ff3681627b | |
Shadowfacts | 35d21fb725 | |
Shadowfacts | bbfb3b0a7a | |
Shadowfacts | 8b78a5e7ad | |
Shadowfacts | 66c17006d1 | |
Shadowfacts | 8a911f238b | |
Shadowfacts | 77c44c323f | |
Shadowfacts | c2d1fe45d8 | |
Shadowfacts | 24591cee05 | |
Shadowfacts | 50dd785ef8 | |
Shadowfacts | af2e95ea39 | |
Shadowfacts | 4fa1bd7268 | |
Shadowfacts | ea07e6aef6 |
13
CHANGELOG.md
13
CHANGELOG.md
|
@ -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
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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> {
|
||||||
|
|
|
@ -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" */;
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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):
|
|
||||||
AccountDisplayNameLabel(account: underlying, fontSize: 14)
|
|
||||||
.foregroundColor(Color(UIColor.label))
|
.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,24 +254,28 @@ struct ComposeAutocompleteEmojisView: View {
|
||||||
|
|
||||||
private var toggleExpandedButton: some View {
|
private var toggleExpandedButton: some View {
|
||||||
Button {
|
Button {
|
||||||
|
withAnimation {
|
||||||
expanded.toggle()
|
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
|
||||||
|
if !query.isEmpty {
|
||||||
|
emojis =
|
||||||
emojis.map { (emoji) -> (Emoji, (matched: Bool, score: Int)) in
|
emojis.map { (emoji) -> (Emoji, (matched: Bool, score: Int)) in
|
||||||
(emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode))
|
(emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode))
|
||||||
}
|
}
|
||||||
|
@ -298,6 +283,13 @@ struct ComposeAutocompleteEmojisView: View {
|
||||||
.sorted { $0.1.score > $1.1.score }
|
.sorted { $0.1.score > $1.1.score }
|
||||||
.map(\.0)
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,22 +37,24 @@ struct ComposeReplyView: View {
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.background(GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
|
.preference(key: DisplayNameHeightPrefKey.self, value: proxy.size.height)
|
||||||
|
.onPreferenceChange(DisplayNameHeightPrefKey.self) { newValue in
|
||||||
|
displayNameHeight = newValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
ComposeReplyContentView(status: status) { (newHeight) in
|
ComposeReplyContentView(status: status) { newHeight in
|
||||||
self.contentHeight = newHeight
|
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()
|
||||||
|
|
|
@ -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(outerMinY: outer.frame(in: .global).minY)
|
mainStack
|
||||||
}
|
}
|
||||||
|
.coordinateSpace(name: ComposeView.coordinateSpaceOutsideOfScrollView)
|
||||||
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
.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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -60,15 +60,18 @@ 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? {
|
||||||
|
switch item {
|
||||||
|
case .loadingIndicator:
|
||||||
|
return self.loadingIndicatorCell(indexPath: indexPath)
|
||||||
|
|
||||||
|
case let .status(id: id, state: state, pinned: pinned):
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell
|
let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell
|
||||||
|
|
||||||
cell.delegate = self
|
cell.delegate = self
|
||||||
// todo: dataSource.sectionIdentifier is only available on iOS 15
|
cell.showPinned = pinned
|
||||||
cell.showPinned = dataSource.snapshot().indexOfSection(.pinned) == indexPath.section
|
cell.updateUI(statusID: id, state: state)
|
||||||
cell.updateUI(statusID: item.id, state: item.state)
|
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
|
override func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
|
||||||
guard accountID != nil else {
|
guard accountID != nil else {
|
||||||
|
@ -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
|
||||||
|
case .status(id: let id, state: _, pinned: _):
|
||||||
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
|
||||||
hasher.combine(id)
|
|
||||||
hasher.combine(pinned)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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,16 +164,22 @@ 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)):
|
||||||
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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, _):
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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,13 +69,9 @@ extension BaseEmojiLabel {
|
||||||
group.leave()
|
group.leave()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// sync back to the main thread to add the dictionary
|
|
||||||
// todo: using the main thread for this isn't great
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
emojiImages[emoji.shortcode] = transformedImage
|
emojiImages[emoji.shortcode] = transformedImage
|
||||||
group.leave()
|
group.leave()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if let request = request {
|
if let request = request {
|
||||||
emojiRequests.append(request)
|
emojiRequests.append(request)
|
||||||
}
|
}
|
||||||
|
@ -92,6 +86,11 @@ extension BaseEmojiLabel {
|
||||||
func buildStringWithEmojisReplaced(usePlaceholders: Bool) -> NSAttributedString {
|
func buildStringWithEmojisReplaced(usePlaceholders: Bool) -> NSAttributedString {
|
||||||
let mutAttrString = NSMutableAttributedString(attributedString: attributedString)
|
let mutAttrString = NSMutableAttributedString(attributedString: attributedString)
|
||||||
|
|
||||||
|
// lock once for the entire loop, rather than lock/unlocking for each iteration to do the lookup
|
||||||
|
// OSAllocatedUnfairLock.withLock expects a @Sendable closure, so this warns about captures of non-sendable types (attribute dstrings, text checking results)
|
||||||
|
// 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)
|
||||||
|
// so, just ignore the warnings
|
||||||
|
emojiImages.withLock { emojiImages in
|
||||||
// replaces the emojis starting from the end of the string as to not alter the indices of preceeding emojis
|
// replaces the emojis starting from the end of the string as to not alter the indices of preceeding emojis
|
||||||
for match in matches.reversed() {
|
for match in matches.reversed() {
|
||||||
let shortcode = (attributedString.string as NSString).substring(with: match.range(at: 1))
|
let shortcode = (attributedString.string as NSString).substring(with: match.range(at: 1))
|
||||||
|
@ -108,6 +107,7 @@ extension BaseEmojiLabel {
|
||||||
let attachmentStr = NSAttributedString(attachment: attachment)
|
let attachmentStr = NSAttributedString(attachment: attachment)
|
||||||
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
|
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return mutAttrString
|
return mutAttrString
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,9 +134,13 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
|
||||||
let oldState = actionsContainerView.isHidden
|
let oldState = actionsContainerView.isHidden
|
||||||
if oldState != Preferences.shared.hideActionsInTimeline {
|
if oldState != Preferences.shared.hideActionsInTimeline {
|
||||||
updateActionsVisibility()
|
updateActionsVisibility()
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
invalidateIntrinsicContentSize()
|
||||||
|
} else {
|
||||||
// not really accurate, but it notifies the vc our height has changed
|
// not really accurate, but it notifies the vc our height has changed
|
||||||
delegate?.statusCellCollapsedStateChanged(self)
|
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,11 +149,33 @@
|
||||||
<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>
|
||||||
|
</subviews>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="gIY-Wp-RSk" firstAttribute="leading" secondItem="QMP-j2-HLn" secondAttribute="trailing" constant="8" id="0Tm-v7-Ts4"/>
|
||||||
|
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="QMP-j2-HLn" secondAttribute="bottom" constant="8" id="2Ao-Gj-fY3"/>
|
||||||
|
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="gIY-Wp-RSk" secondAttribute="bottom" id="6OU-Ub-VH8"/>
|
||||||
|
<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="gIY-Wp-RSk" firstAttribute="top" secondItem="QMP-j2-HLn" secondAttribute="top" id="fEd-wN-kuQ"/>
|
||||||
|
<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 firstAttribute="bottom" secondItem="gIY-Wp-RSk" secondAttribute="bottom" id="kq7-bk-S8j"/>
|
||||||
|
<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="QMP-j2-HLn" firstAttribute="leading" secondItem="ve3-Y1-NQH" secondAttribute="leading" id="zeW-tQ-uJl"/>
|
||||||
|
</constraints>
|
||||||
|
<variation key="default">
|
||||||
|
<mask key="constraints">
|
||||||
|
<exclude reference="kq7-bk-S8j"/>
|
||||||
|
</mask>
|
||||||
|
</variation>
|
||||||
|
</view>
|
||||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="TUP-Nz-5Yh">
|
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="TUP-Nz-5Yh">
|
||||||
<rect key="frame" x="0.0" y="169.5" width="335" height="26"/>
|
<rect key="frame" x="0.0" y="198" width="343" height="26"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="rKF-yF-KIa">
|
<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"/>
|
<rect key="frame" x="0.0" y="0.0" width="86" height="26"/>
|
||||||
<accessibility key="accessibilityConfiguration" label="Reply"/>
|
<accessibility key="accessibilityConfiguration" label="Reply"/>
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="18"/>
|
<fontDescription key="fontDescription" type="system" pointSize="18"/>
|
||||||
<state key="normal" image="arrowshape.turn.up.left.fill" catalog="system"/>
|
<state key="normal" image="arrowshape.turn.up.left.fill" catalog="system"/>
|
||||||
|
@ -161,7 +184,7 @@
|
||||||
</connections>
|
</connections>
|
||||||
</button>
|
</button>
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="6tW-z8-Qh9">
|
<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"/>
|
<rect key="frame" x="171.5" y="0.0" width="86" height="26"/>
|
||||||
<accessibility key="accessibilityConfiguration" label="Reblog"/>
|
<accessibility key="accessibilityConfiguration" label="Reblog"/>
|
||||||
<state key="normal" image="repeat" catalog="system"/>
|
<state key="normal" image="repeat" catalog="system"/>
|
||||||
<connections>
|
<connections>
|
||||||
|
@ -169,7 +192,7 @@
|
||||||
</connections>
|
</connections>
|
||||||
</button>
|
</button>
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="982-J4-NGl">
|
<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"/>
|
<rect key="frame" x="257.5" y="0.0" width="85.5" height="26"/>
|
||||||
<accessibility key="accessibilityConfiguration" label="More Actions"/>
|
<accessibility key="accessibilityConfiguration" label="More Actions"/>
|
||||||
<state key="normal" image="ellipsis" catalog="system"/>
|
<state key="normal" image="ellipsis" catalog="system"/>
|
||||||
<connections>
|
<connections>
|
||||||
|
@ -177,7 +200,7 @@
|
||||||
</connections>
|
</connections>
|
||||||
</button>
|
</button>
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="x0t-TR-jJ4">
|
<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"/>
|
<rect key="frame" x="86" y="0.0" width="85.5" height="26"/>
|
||||||
<accessibility key="accessibilityConfiguration" label="Favorite"/>
|
<accessibility key="accessibilityConfiguration" label="Favorite"/>
|
||||||
<state key="normal" image="star.fill" catalog="system"/>
|
<state key="normal" image="star.fill" catalog="system"/>
|
||||||
<connections>
|
<connections>
|
||||||
|
@ -206,31 +229,6 @@
|
||||||
</constraints>
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
</subviews>
|
</subviews>
|
||||||
<constraints>
|
|
||||||
<constraint firstItem="gIY-Wp-RSk" firstAttribute="leading" secondItem="QMP-j2-HLn" secondAttribute="trailing" constant="8" id="0Tm-v7-Ts4"/>
|
|
||||||
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="QMP-j2-HLn" secondAttribute="bottom" constant="8" id="2Ao-Gj-fY3"/>
|
|
||||||
<constraint firstItem="TUP-Nz-5Yh" firstAttribute="trailing" secondItem="ve3-Y1-NQH" secondAttribute="trailingMargin" id="3l0-tE-Ak1"/>
|
|
||||||
<constraint firstItem="TUP-Nz-5Yh" firstAttribute="top" secondItem="gIY-Wp-RSk" secondAttribute="bottom" 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="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="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 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="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="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"/>
|
|
||||||
</constraints>
|
|
||||||
<variation key="default">
|
|
||||||
<mask key="constraints">
|
|
||||||
<exclude reference="kq7-bk-S8j"/>
|
|
||||||
</mask>
|
|
||||||
</variation>
|
|
||||||
</view>
|
|
||||||
</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"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
|
@ -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"/>
|
||||||
|
|
|
@ -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? {
|
||||||
|
|
|
@ -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?) {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue