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
|
||||
|
||||
## 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)
|
||||
Features/Improvements:
|
||||
- Show notifications when subscribed to other people's posts
|
||||
|
|
|
@ -20,8 +20,9 @@ public protocol StatusProtocol {
|
|||
var createdAt: Date { get }
|
||||
var reblogsCount: Int { get }
|
||||
var favouritesCount: Int { get }
|
||||
var reblogged: Bool { get }
|
||||
var favourited: Bool { get }
|
||||
// pachyderm impl wants Bool, StatusMO wants optional. not sure how to resolve it, but we don't need this currently
|
||||
// var reblogged: Bool { get }
|
||||
// var favourited: Bool { get }
|
||||
var sensitive: Bool { get }
|
||||
var spoilerText: String { get }
|
||||
var visibility: Pachyderm.Status.Visibility { get }
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public final class Status: /*StatusProtocol,*/ Decodable {
|
||||
public final class Status: StatusProtocol, Decodable {
|
||||
public let id: String
|
||||
public let uri: String
|
||||
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)")
|
||||
}
|
||||
|
||||
public static func reblog(_ statusID: String) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/reblog")
|
||||
public static func reblog(_ statusID: String, visibility: Visibility? = nil) -> Request<Status> {
|
||||
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> {
|
||||
|
|
|
@ -50,6 +50,7 @@
|
|||
D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A5402635FB3C0095BD04 /* PollOptionView.swift */; };
|
||||
D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A542263634100095BD04 /* PollOptionCheckboxView.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 */; };
|
||||
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626493623C0FD0000612E6E /* AllPhotosTableViewCell.swift */; };
|
||||
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 */; };
|
||||
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.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 */; };
|
||||
D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.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 */; };
|
||||
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */; };
|
||||
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; };
|
||||
D670F8B62537DC890046588A /* EmojiPickerWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D670F8B52537DC890046588A /* EmojiPickerWrapper.swift */; };
|
||||
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; };
|
||||
D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7B2157E01900721E32 /* XCBManager.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 */; };
|
||||
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.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 */; };
|
||||
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.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 */; };
|
||||
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.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 */; };
|
||||
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -489,7 +491,6 @@
|
|||
D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTableViewController.swift; sourceTree = "<group>"; };
|
||||
D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Equatable.swift"; sourceTree = "<group>"; };
|
||||
D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; };
|
||||
D670F8B52537DC890046588A /* EmojiPickerWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerWrapper.swift; sourceTree = "<group>"; };
|
||||
D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Pachyderm; 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>"; };
|
||||
|
@ -514,6 +515,8 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -590,8 +593,6 @@
|
|||
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = "<group>"; };
|
||||
D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D6C143D9253510F4007DC240 /* 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>"; };
|
||||
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>"; };
|
||||
|
@ -668,6 +669,7 @@
|
|||
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */,
|
||||
D69CCBBF249E6EFD000AF167 /* CrashReporter in Frameworks */,
|
||||
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */,
|
||||
D6552367289870790048A653 /* ScreenCorners in Frameworks */,
|
||||
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -973,9 +975,6 @@
|
|||
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */,
|
||||
D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */,
|
||||
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */,
|
||||
D6C143DF25354E34007DC240 /* EmojiPickerCollectionViewController.swift */,
|
||||
D6C143FB25354FD0007DC240 /* EmojiCollectionViewCell.swift */,
|
||||
D670F8B52537DC890046588A /* EmojiPickerWrapper.swift */,
|
||||
);
|
||||
path = Compose;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1258,6 +1257,7 @@
|
|||
children = (
|
||||
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
|
||||
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */,
|
||||
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
|
||||
D620483523D38075008A63EF /* ContentTextView.swift */,
|
||||
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
|
||||
D6969E9F240C8384002843CE /* EmojiLabel.swift */,
|
||||
|
@ -1266,6 +1266,7 @@
|
|||
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */,
|
||||
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */,
|
||||
D620483323D3801D008A63EF /* LinkTextView.swift */,
|
||||
D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */,
|
||||
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
|
||||
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
|
||||
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
|
||||
|
@ -1297,6 +1298,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */,
|
||||
D6895DC128D65274006341DA /* CustomAlertController.swift */,
|
||||
D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */,
|
||||
D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */,
|
||||
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */,
|
||||
|
@ -1506,6 +1508,7 @@
|
|||
D60CFFDA24A290BA00D00083 /* SwiftSoup */,
|
||||
D6676CA427A8D0020052936B /* WebURLFoundationExtras */,
|
||||
D674A50827F9128D00BA03AC /* Pachyderm */,
|
||||
D6552366289870790048A653 /* ScreenCorners */,
|
||||
);
|
||||
productName = Tusker;
|
||||
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
|
||||
|
@ -1614,6 +1617,7 @@
|
|||
D69CCBBD249E6EFD000AF167 /* XCRemoteSwiftPackageReference "plcrashreporter" */,
|
||||
D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
|
||||
D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */,
|
||||
D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */,
|
||||
);
|
||||
productRefGroup = D6D4DDCD212518A000E1C4BB /* Products */;
|
||||
projectDirPath = "";
|
||||
|
@ -1772,6 +1776,7 @@
|
|||
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */,
|
||||
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */,
|
||||
D6B22A0F2560D52D004D82EF /* TabbedPageViewController.swift in Sources */,
|
||||
D6895DC228D65274006341DA /* CustomAlertController.swift in Sources */,
|
||||
D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */,
|
||||
0411610022B442870030A9B7 /* LoadingLargeImageViewController.swift in Sources */,
|
||||
D6A4DCE525537C7A00D9DE31 /* FastSwitchingAccountView.swift in Sources */,
|
||||
|
@ -1797,7 +1802,6 @@
|
|||
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
|
||||
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
|
||||
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */,
|
||||
D6C143FD25354FD0007DC240 /* EmojiCollectionViewCell.swift in Sources */,
|
||||
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
|
||||
D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */,
|
||||
D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */,
|
||||
|
@ -1819,7 +1823,6 @@
|
|||
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */,
|
||||
D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */,
|
||||
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
|
||||
D6C143E025354E34007DC240 /* EmojiPickerCollectionViewController.swift in Sources */,
|
||||
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */,
|
||||
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
|
||||
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
|
||||
|
@ -1914,6 +1917,7 @@
|
|||
D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */,
|
||||
D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */,
|
||||
D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */,
|
||||
D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */,
|
||||
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */,
|
||||
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
|
||||
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,
|
||||
|
@ -1925,7 +1929,6 @@
|
|||
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */,
|
||||
D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */,
|
||||
D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */,
|
||||
D670F8B62537DC890046588A /* EmojiPickerWrapper.swift in Sources */,
|
||||
D6B81F442560390300F6E31D /* MenuController.swift in Sources */,
|
||||
D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */,
|
||||
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */,
|
||||
|
@ -1947,6 +1950,7 @@
|
|||
D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */,
|
||||
D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */,
|
||||
D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */,
|
||||
D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */,
|
||||
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */,
|
||||
D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */,
|
||||
04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */,
|
||||
|
@ -2198,7 +2202,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 33;
|
||||
CURRENT_PROJECT_VERSION = 35;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
|
@ -2228,7 +2232,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 33;
|
||||
CURRENT_PROJECT_VERSION = 35;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
|
@ -2337,7 +2341,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 33;
|
||||
CURRENT_PROJECT_VERSION = 35;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
|
@ -2364,7 +2368,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 33;
|
||||
CURRENT_PROJECT_VERSION = 35;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
|
@ -2443,6 +2447,14 @@
|
|||
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" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/karwa/swift-url";
|
||||
|
@ -2467,6 +2479,11 @@
|
|||
package = D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
|
||||
productName = SwiftSoup;
|
||||
};
|
||||
D6552366289870790048A653 /* ScreenCorners */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */;
|
||||
productName = ScreenCorners;
|
||||
};
|
||||
D6676CA427A8D0020052936B /* WebURLFoundationExtras */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */;
|
||||
|
|
|
@ -24,7 +24,7 @@ class ImageCache {
|
|||
private let cache: ImageDataCache
|
||||
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)
|
||||
|
||||
|
@ -73,6 +73,7 @@ class ImageCache {
|
|||
}
|
||||
|
||||
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
|
||||
_ = get(url, loadOriginal: loadOriginal) { data, image in
|
||||
continuation.resume(returning: (data, image))
|
||||
|
@ -96,7 +97,7 @@ class ImageCache {
|
|||
if let data = data {
|
||||
try? self.cache.set(url.absoluteString, data: data, image: image)
|
||||
}
|
||||
self.groups.removeValueWithoutReturning(forKey: url)
|
||||
_ = self.groups.removeValue(forKey: url)
|
||||
}
|
||||
groups[url] = group
|
||||
return group
|
||||
|
|
|
@ -20,13 +20,15 @@ class MastodonCachePersistentStore: NSPersistentContainer {
|
|||
|
||||
private(set) lazy var backgroundContext: NSManagedObjectContext = {
|
||||
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
||||
context.parent = self.viewContext
|
||||
context.persistentStoreCoordinator = self.persistentStoreCoordinator
|
||||
context.automaticallyMergesChangesFromParent = true
|
||||
return context
|
||||
}()
|
||||
|
||||
private(set) lazy var prefetchBackgroundContext: NSManagedObjectContext = {
|
||||
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
||||
context.parent = self.viewContext
|
||||
context.persistentStoreCoordinator = self.persistentStoreCoordinator
|
||||
context.automaticallyMergesChangesFromParent = true
|
||||
return context
|
||||
}()
|
||||
|
||||
|
@ -51,6 +53,8 @@ class MastodonCachePersistentStore: NSPersistentContainer {
|
|||
}
|
||||
}
|
||||
|
||||
viewContext.automaticallyMergesChangesFromParent = true
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext)
|
||||
}
|
||||
|
||||
|
|
|
@ -10,8 +10,11 @@ import Foundation
|
|||
import Pachyderm
|
||||
|
||||
struct InstanceFeatures {
|
||||
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; Pleroma (.*)\\)")
|
||||
|
||||
private(set) var instanceType = InstanceType.mastodon
|
||||
private(set) var version: Version?
|
||||
private(set) var pleromaVersion: Version?
|
||||
private(set) var maxStatusChars = 500
|
||||
|
||||
var localOnlyPosts: Bool {
|
||||
|
@ -39,7 +42,12 @@ struct InstanceFeatures {
|
|||
}
|
||||
|
||||
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?) {
|
||||
|
@ -58,8 +66,20 @@ struct InstanceFeatures {
|
|||
|
||||
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
|
||||
}
|
||||
|
||||
func hasVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
|
||||
if let version {
|
||||
return version >= Version(major, minor, patch)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension InstanceFeatures {
|
||||
|
@ -83,6 +103,8 @@ extension InstanceFeatures {
|
|||
|
||||
extension InstanceFeatures {
|
||||
struct Version: Equatable, Comparable {
|
||||
private static let regex = try! NSRegularExpression(pattern: "^(\\d+)\\.(\\d+)\\.(\\d+).*$")
|
||||
|
||||
let major: Int
|
||||
let minor: Int
|
||||
let patch: Int
|
||||
|
@ -94,8 +116,7 @@ extension InstanceFeatures {
|
|||
}
|
||||
|
||||
init?(string: String) {
|
||||
let regex = try! NSRegularExpression(pattern: "^(\\d+)\\.(\\d+)\\.(\\d+).*$")
|
||||
guard let match = regex.firstMatch(in: string, range: NSRange(location: 0, length: string.utf16.count)),
|
||||
guard let match = Version.regex.firstMatch(in: string, range: NSRange(location: 0, length: string.utf16.count)),
|
||||
match.numberOfRanges == 4 else {
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
import Combine
|
||||
import CryptoKit
|
||||
|
||||
class LocalData: ObservableObject {
|
||||
|
||||
|
@ -22,7 +23,6 @@ class LocalData: ObservableObject {
|
|||
if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING_LOGIN") {
|
||||
accounts = [
|
||||
UserAccountInfo(
|
||||
id: UUID().uuidString,
|
||||
instanceURL: URL(string: "http://localhost:8080")!,
|
||||
clientID: "client_id",
|
||||
clientSecret: "client_secret",
|
||||
|
@ -33,23 +33,15 @@ class LocalData: ObservableObject {
|
|||
} else {
|
||||
defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")!
|
||||
}
|
||||
|
||||
migrateAccountIDsIfNecessary()
|
||||
}
|
||||
|
||||
private let accountsKey = "accounts"
|
||||
var accounts: [UserAccountInfo] {
|
||||
private(set) var accounts: [UserAccountInfo] {
|
||||
get {
|
||||
if let array = defaults.array(forKey: accountsKey) as? [[String: String]] {
|
||||
return array.compactMap { (info) in
|
||||
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)
|
||||
}
|
||||
return array.compactMap(UserAccountInfo.init(userDefaultsDict:))
|
||||
} else {
|
||||
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 {
|
||||
return !accounts.isEmpty
|
||||
}
|
||||
|
@ -93,20 +120,12 @@ class LocalData: ObservableObject {
|
|||
if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) {
|
||||
accounts.remove(at: index)
|
||||
}
|
||||
let id = UUID().uuidString
|
||||
let info = UserAccountInfo(id: id, instanceURL: url, clientID: clientID, clientSecret: secret, username: username, accessToken: accessToken)
|
||||
let info = UserAccountInfo(instanceURL: url, clientID: clientID, clientSecret: secret, username: username, accessToken: accessToken)
|
||||
accounts.append(info)
|
||||
self.accounts = accounts
|
||||
return info
|
||||
}
|
||||
|
||||
func setUsername(for info: UserAccountInfo, username: String) {
|
||||
var info = info
|
||||
info.username = username
|
||||
removeAccount(info)
|
||||
accounts.append(info)
|
||||
}
|
||||
|
||||
func removeAccount(_ info: UserAccountInfo) {
|
||||
accounts.removeAll(where: { $0.id == info.id })
|
||||
}
|
||||
|
@ -138,9 +157,57 @@ extension LocalData {
|
|||
let instanceURL: URL
|
||||
let clientID: String
|
||||
let clientSecret: String
|
||||
fileprivate(set) var username: String!
|
||||
private(set) var username: 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) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
|
|
@ -7,52 +7,79 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
class MultiThreadDictionary<Key: Hashable, Value> {
|
||||
private let name: String
|
||||
private var dict = [Key: Value]()
|
||||
private let queue: DispatchQueue
|
||||
// once we target iOS 16, replace uses of this with OSAllocatedUnfairLock<[Key: Value]>
|
||||
// to make the lock semantics more clear
|
||||
@available(iOS, obsoleted: 16.0)
|
||||
class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable> {
|
||||
private let lock: any Lock<[Key: Value]>
|
||||
|
||||
init(name: String) {
|
||||
self.name = name
|
||||
self.queue = DispatchQueue(label: "MultiThreadDictionary (\(name)) Coordinator", attributes: .concurrent)
|
||||
init() {
|
||||
if #available(iOS 16.0, *) {
|
||||
self.lock = OSAllocatedUnfairLock(initialState: [:])
|
||||
} else {
|
||||
self.lock = UnfairLock(initialState: [:])
|
||||
}
|
||||
}
|
||||
|
||||
subscript(key: Key) -> Value? {
|
||||
get {
|
||||
var result: Value? = nil
|
||||
queue.sync {
|
||||
result = dict[key]
|
||||
return lock.withLock { dict in
|
||||
dict[key]
|
||||
}
|
||||
return result
|
||||
}
|
||||
set(value) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.dict[key] = value
|
||||
lock.withLock { dict in
|
||||
dict[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeValueWithoutReturning(forKey key: Key) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.dict.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
/// If the result of this function is unused, it is preferable to use `removeValueWithoutReturning` as it executes asynchronously and doesn't block the calling thread.
|
||||
func removeValue(forKey key: Key) -> Value? {
|
||||
var value: Value? = nil
|
||||
queue.sync(flags: .barrier) {
|
||||
value = dict.removeValue(forKey: key)
|
||||
return lock.withLock { dict in
|
||||
dict.removeValue(forKey: key)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func contains(key: Key) -> Bool {
|
||||
var value: Bool!
|
||||
queue.sync {
|
||||
value = dict.keys.contains(key)
|
||||
}
|
||||
return value
|
||||
return lock.withLock { dict in
|
||||
dict.keys.contains(key)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// can't use AccountProtocol because of associated type requirements
|
||||
@State private var accounts: [EitherAccount] = []
|
||||
@State private var accounts: [AnyAccount] = []
|
||||
|
||||
@State private var searchRequest: URLSessionTask?
|
||||
|
||||
|
@ -56,26 +56,20 @@ struct ComposeAutocompleteMentionsView: View {
|
|||
ScrollView(.horizontal) {
|
||||
// can't use LazyHStack because changing the contents of the ForEach causes the ScrollView to hang
|
||||
HStack(spacing: 8) {
|
||||
ForEach(accounts, id: \.id) { (account) in
|
||||
ForEach(accounts, id: \.value.id) { (account) in
|
||||
Button {
|
||||
uiState.currentInput?.autocomplete(with: "@\(account.acct)")
|
||||
uiState.currentInput?.autocomplete(with: "@\(account.value.acct)")
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
ComposeAvatarImageView(url: account.avatar)
|
||||
ComposeAvatarImageView(url: account.value.avatar)
|
||||
.frame(width: 30, height: 30)
|
||||
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 30)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
switch account {
|
||||
case let .pachyderm(underlying):
|
||||
AccountDisplayNameLabel(account: underlying, fontSize: 14)
|
||||
AccountDisplayNameLabel(account: account.value, fontSize: 14)
|
||||
.foregroundColor(Color(UIColor.label))
|
||||
case let .coreData(underlying):
|
||||
AccountDisplayNameLabel(account: underlying, fontSize: 14)
|
||||
.foregroundColor(Color(UIColor.label))
|
||||
}
|
||||
|
||||
Text(verbatim: "@\(account.acct)")
|
||||
Text(verbatim: "@\(account.value.acct)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(Color(UIColor.label))
|
||||
}
|
||||
|
@ -110,7 +104,7 @@ struct ComposeAutocompleteMentionsView: View {
|
|||
request.predicate = NSPredicate(format: "displayName LIKE %@ OR acct LIKE %@", wildcardedQuery, wildcardedQuery)
|
||||
|
||||
if let results = try? mastodonController.persistentContainer.viewContext.fetch(request) {
|
||||
loadAccounts(results.map { .coreData($0) }, query: query)
|
||||
loadAccounts(results.map { .init(value: $0) }, query: query)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -131,27 +125,27 @@ struct ComposeAutocompleteMentionsView: View {
|
|||
DispatchQueue.main.async {
|
||||
// if the query has changed, don't bother loading the now-outdated results
|
||||
if case .mention(query) = uiState.autocompleteState {
|
||||
self.loadAccounts(accounts.map { .pachyderm($0) }, query: query)
|
||||
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
|
||||
let ignoreDomain = !query.contains("@")
|
||||
|
||||
self.accounts =
|
||||
accounts.map { (account: EitherAccount) -> (EitherAccount, (matched: Bool, score: Int)) in
|
||||
let fuzzyStr = ignoreDomain ? String(account.acct.split(separator: "@").first!) : account.acct
|
||||
accounts.map { (account) -> (AnyAccount, (matched: Bool, score: Int)) in
|
||||
let fuzzyStr = ignoreDomain ? String(account.value.acct.split(separator: "@").first!) : account.value.acct
|
||||
let res = (account, FuzzyMatcher.match(pattern: query, str: fuzzyStr))
|
||||
return res
|
||||
}
|
||||
.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
|
||||
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 {
|
||||
score += 3
|
||||
}
|
||||
|
@ -165,39 +159,11 @@ struct ComposeAutocompleteMentionsView: View {
|
|||
.map(\.0)
|
||||
}
|
||||
|
||||
private enum EitherAccount: Equatable {
|
||||
case pachyderm(Account)
|
||||
case coreData(AccountMO)
|
||||
private struct AnyAccount: Equatable {
|
||||
let value: any AccountProtocol
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .pachyderm(account):
|
||||
return account.id
|
||||
case let .coreData(account):
|
||||
return account.id
|
||||
}
|
||||
}
|
||||
|
||||
var acct: String {
|
||||
switch self {
|
||||
case let .pachyderm(account):
|
||||
return account.acct
|
||||
case let .coreData(account):
|
||||
return account.acct
|
||||
}
|
||||
}
|
||||
|
||||
var avatar: URL? {
|
||||
switch self {
|
||||
case let .pachyderm(account):
|
||||
return account.avatar
|
||||
case let .coreData(account):
|
||||
return account.avatar
|
||||
}
|
||||
}
|
||||
|
||||
static func ==(lhs: EitherAccount, rhs: EitherAccount) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
static func ==(lhs: AnyAccount, rhs: AnyAccount) -> Bool {
|
||||
return lhs.value.id == rhs.value.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -214,8 +180,14 @@ struct ComposeAutocompleteEmojisView: View {
|
|||
HStack(alignment: expanded ? .top : .center, spacing: 0) {
|
||||
if case let .emoji(query) = uiState.autocompleteState {
|
||||
emojiList(query: query)
|
||||
.animation(.default, value: expanded)
|
||||
.transition(.move(edge: .bottom))
|
||||
.onReceive(uiState.$autocompleteState, perform: queryChanged)
|
||||
.onAppear {
|
||||
if uiState.shouldEmojiAutocompletionBeginExpanded {
|
||||
expanded = true
|
||||
uiState.shouldEmojiAutocompletionBeginExpanded = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// when the autocomplete view is animating out, the autocomplete state is nil
|
||||
// add a spacer so the expand button remains on the right
|
||||
|
@ -231,18 +203,28 @@ struct ComposeAutocompleteEmojisView: View {
|
|||
@ViewBuilder
|
||||
private func emojiList(query: String) -> some View {
|
||||
if expanded {
|
||||
EmojiPickerWrapper(searchQuery: query)
|
||||
verticalGrid
|
||||
.frame(height: 150)
|
||||
} else {
|
||||
horizontalScrollView
|
||||
.onReceive(uiState.$autocompleteState, perform: queryChanged)
|
||||
.onAppear {
|
||||
if uiState.shouldEmojiAutocompletionBeginExpanded {
|
||||
expanded = true
|
||||
uiState.shouldEmojiAutocompletionBeginExpanded = false
|
||||
}
|
||||
}
|
||||
|
||||
private var verticalGrid: some View {
|
||||
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 {
|
||||
|
@ -260,7 +242,6 @@ struct ComposeAutocompleteEmojisView: View {
|
|||
}
|
||||
}
|
||||
.frame(height: 30)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.animation(.linear(duration: 0.2), value: emojis)
|
||||
|
||||
|
@ -273,24 +254,28 @@ struct ComposeAutocompleteEmojisView: View {
|
|||
|
||||
private var toggleExpandedButton: some View {
|
||||
Button {
|
||||
withAnimation {
|
||||
expanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: expanded ? "chevron.down" : "chevron.up")
|
||||
Image(systemName: "chevron.down")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.rotationEffect(expanded ? .zero : .degrees(180))
|
||||
}
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
|
||||
private func queryChanged(_ autocompleteState: ComposeUIState.AutocompleteState?) {
|
||||
guard case let .emoji(query) = autocompleteState,
|
||||
!query.isEmpty else {
|
||||
guard case let .emoji(query) = autocompleteState else {
|
||||
emojis = []
|
||||
return
|
||||
}
|
||||
|
||||
mastodonController.getCustomEmojis { (emojis) in
|
||||
self.emojis =
|
||||
var emojis = emojis
|
||||
if !query.isEmpty {
|
||||
emojis =
|
||||
emojis.map { (emoji) -> (Emoji, (matched: Bool, score: Int)) in
|
||||
(emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode))
|
||||
}
|
||||
|
@ -298,6 +283,13 @@ struct ComposeAutocompleteEmojisView: View {
|
|||
.sorted { $0.1.score > $1.1.score }
|
||||
.map(\.0)
|
||||
}
|
||||
var shortcodes = Set<String>()
|
||||
self.emojis = []
|
||||
for emoji in emojis where !shortcodes.contains(emoji.shortcode) {
|
||||
self.emojis.append(emoji)
|
||||
shortcodes.insert(emoji.shortcode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ struct ComposeReplyContentView: UIViewRepresentable {
|
|||
view.setTextFrom(status: status)
|
||||
view.isUserInteractionEnabled = false
|
||||
view.backgroundColor = .clear
|
||||
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
|
||||
return view
|
||||
}
|
||||
|
|
|
@ -11,8 +11,8 @@ import SwiftUI
|
|||
struct ComposeReplyView: View {
|
||||
let status: StatusMO
|
||||
let stackPadding: CGFloat
|
||||
let outerMinY: CGFloat
|
||||
|
||||
@State private var displayNameHeight: CGFloat?
|
||||
@State private var contentHeight: CGFloat?
|
||||
|
||||
@ObservedObject private var preferences = Preferences.shared
|
||||
|
@ -37,22 +37,24 @@ struct ComposeReplyView: View {
|
|||
|
||||
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
|
||||
self.contentHeight = newHeight
|
||||
ComposeReplyContentView(status: status) { newHeight in
|
||||
contentHeight = newHeight
|
||||
}
|
||||
.offset(x: -4, y: -8)
|
||||
.padding(.bottom, -8)
|
||||
.frame(height: contentHeight ?? 0)
|
||||
}
|
||||
.frame(height: max(50, contentHeight ?? 0) + 8)
|
||||
}
|
||||
.padding(.bottom, -8)
|
||||
}
|
||||
|
||||
private func replyAvatarImage(geometry: GeometryProxy) -> some View {
|
||||
// using named coordinate spaces produces an incorrect scroll offset on iOS 13,
|
||||
// so simply compare the geometry inside and outside the scroll view in the global coordinate space
|
||||
let scrollOffset = outerMinY - geometry.frame(in: .global).minY
|
||||
let scrollOffset = -geometry.frame(in: .named(ComposeView.coordinateSpaceOutsideOfScrollView)).minY
|
||||
|
||||
// add stackPadding so that the image is always at least stackPadding away from the top
|
||||
var offset = scrollOffset + stackPadding
|
||||
|
@ -61,7 +63,7 @@ struct ComposeReplyView: View {
|
|||
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
|
||||
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
|
||||
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 {
|
||||
// static var previews: some View {
|
||||
// ComposeReplyView()
|
||||
|
|
|
@ -42,6 +42,8 @@ import Combine
|
|||
}
|
||||
|
||||
struct ComposeView: View {
|
||||
static let coordinateSpaceOutsideOfScrollView = "coordinateSpaceOutsideOfScrollView"
|
||||
|
||||
@ObservedObject var draft: Draft
|
||||
@EnvironmentObject var mastodonController: MastodonController
|
||||
@EnvironmentObject var uiState: ComposeUIState
|
||||
|
@ -77,20 +79,12 @@ struct ComposeView: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
mostOfTheBody.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { cancelButton }
|
||||
ToolbarItem(placement: .confirmationAction) { postButton }
|
||||
}
|
||||
}
|
||||
|
||||
var mostOfTheBody: some View {
|
||||
ZStack(alignment: .top) {
|
||||
GeometryReader { (outer) in
|
||||
ScrollView(.vertical) {
|
||||
mainStack(outerMinY: outer.frame(in: .global).minY)
|
||||
mainStack
|
||||
}
|
||||
.coordinateSpace(name: ComposeView.coordinateSpaceOutsideOfScrollView)
|
||||
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||
}
|
||||
|
||||
if let poster = poster {
|
||||
// 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"))
|
||||
)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { cancelButton }
|
||||
ToolbarItem(placement: .confirmationAction) { postButton }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
@ -122,14 +120,13 @@ struct ComposeView: View {
|
|||
.animation(.default, value: uiState.autocompleteState)
|
||||
}
|
||||
|
||||
func mainStack(outerMinY: CGFloat) -> some View {
|
||||
var mainStack: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let id = draft.inReplyToID,
|
||||
let status = mastodonController.persistentContainer.status(for: id) {
|
||||
ComposeReplyView(
|
||||
status: status,
|
||||
stackPadding: stackPadding,
|
||||
outerMinY: outerMinY
|
||||
stackPadding: stackPadding
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
noteTextView.setTextFromHtml(account.note)
|
||||
noteTextView.setEmojis(account.emojis)
|
||||
noteTextView.setEmojis(account.emojis, identifier: account.id)
|
||||
|
||||
avatarImageView.image = nil
|
||||
if let avatar = account.avatar {
|
||||
|
|
|
@ -140,9 +140,12 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
let notchedDeviceTopInsets: [CGFloat] = [
|
||||
44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max
|
||||
48, // iPhone XR, 11
|
||||
47, // iPhone 12, 12 Pro, 12 Pro Max, 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
|
||||
]
|
||||
let pillDeviceTopInsets: [CGFloat] = [
|
||||
59, // iPhone 14 Pro, 14 Pro Max
|
||||
]
|
||||
if notchedDeviceTopInsets.contains(view.safeAreaInsets.top) {
|
||||
// the notch width is not the same for the iPhones 13,
|
||||
// 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
|
||||
shareButtonLeadingConstraint.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"?>
|
||||
<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"/>
|
||||
<dependencies>
|
||||
<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="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</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 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 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"/>
|
||||
</constraints>
|
||||
</view>
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import ScreenCorners
|
||||
|
||||
class AccountSwitchingContainerViewController: UIViewController {
|
||||
|
||||
|
@ -49,14 +50,30 @@ class AccountSwitchingContainerViewController: UIViewController {
|
|||
} else {
|
||||
let sign: CGFloat = direction == .downwards ? -1 : 1
|
||||
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
|
||||
oldRoot.view.transform = CGAffineTransform(translationX: 0, y: -newInitialOffset)
|
||||
} completion: { (_) in
|
||||
oldRoot.removeViewAndController()
|
||||
newRoot.view.layer.masksToBounds = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
class NotificationsTableViewController: DiffableTimelineLikeTableViewController<NotificationsTableViewController.Section, NotificationGroup> {
|
||||
class NotificationsTableViewController: DiffableTimelineLikeTableViewController<NotificationsTableViewController.Section, NotificationsTableViewController.Item> {
|
||||
|
||||
private let statusCell = "statusCell"
|
||||
private let actionGroupCell = "actionGroupCell"
|
||||
|
@ -56,7 +56,12 @@ class NotificationsTableViewController: 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 {
|
||||
case .mention:
|
||||
guard let notification = group.notifications.first,
|
||||
|
@ -107,18 +112,18 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
|
|||
case let .failure(error):
|
||||
completion(.failure(.client(error)))
|
||||
|
||||
case let .success(notifications, _):
|
||||
case let .success(notifications, pagination):
|
||||
let groups = NotificationGroup.createGroups(notifications: notifications, only: self.groupTypes)
|
||||
|
||||
if !notifications.isEmpty {
|
||||
self.newer = .after(id: notifications.first!.id, count: nil)
|
||||
self.older = .before(id: notifications.last!.id, count: nil)
|
||||
self.newer = pagination?.newer ?? .after(id: notifications.first!.id, count: nil)
|
||||
self.older = pagination?.older ?? .before(id: notifications.last!.id, count: nil)
|
||||
}
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(notifications: notifications) {
|
||||
var snapshot = Snapshot()
|
||||
snapshot.appendSections([.notifications])
|
||||
snapshot.appendItems(groups, toSection: .notifications)
|
||||
snapshot.appendItems(groups.map { .notificationGroup($0) }, toSection: .notifications)
|
||||
completion(.success(snapshot))
|
||||
}
|
||||
}
|
||||
|
@ -137,19 +142,19 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
|
|||
case let .failure(error):
|
||||
completion(.failure(.client(error)))
|
||||
|
||||
case let .success(newNotifications, _):
|
||||
case let .success(newNotifications, pagination):
|
||||
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)
|
||||
|
||||
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)
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, NotificationGroup>()
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.notifications])
|
||||
snapshot.appendItems(merged, toSection: .notifications)
|
||||
snapshot.appendItems(merged.map { .notificationGroup($0) }, toSection: .notifications)
|
||||
completion(.success(snapshot))
|
||||
}
|
||||
}
|
||||
|
@ -168,22 +173,22 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
|
|||
case let .failure(error):
|
||||
completion(.failure(.client(error)))
|
||||
|
||||
case let .success(newNotifications, _):
|
||||
case let .success(newNotifications, pagination):
|
||||
guard !newNotifications.isEmpty else {
|
||||
completion(.failure(.allCaughtUp))
|
||||
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)
|
||||
|
||||
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)
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, NotificationGroup>()
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.notifications])
|
||||
snapshot.appendItems(merged, toSection: .notifications)
|
||||
snapshot.appendItems(merged.map { .notificationGroup($0) }, toSection: .notifications)
|
||||
completion(.success(snapshot))
|
||||
}
|
||||
}
|
||||
|
@ -191,9 +196,12 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
|
|||
}
|
||||
|
||||
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()
|
||||
item.notifications
|
||||
notifications
|
||||
.map { Pachyderm.Notification.dismiss(id: $0.id) }
|
||||
.forEach { (request) in
|
||||
group.enter()
|
||||
|
@ -241,9 +249,23 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
|
|||
}
|
||||
|
||||
extension NotificationsTableViewController {
|
||||
enum Section: CaseIterable, Hashable {
|
||||
enum Section: DiffableTimelineLikeSection {
|
||||
case loadingIndicator
|
||||
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 {
|
||||
|
@ -265,7 +287,7 @@ extension NotificationsTableViewController: StatusTableViewCellDelegate {
|
|||
extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
|
||||
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||
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 {
|
||||
guard let avatar = notification.account.avatar else { continue }
|
||||
ImageCache.avatars.fetchIfNotCached(avatar)
|
||||
|
@ -275,7 +297,7 @@ extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
|
|||
|
||||
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
|
||||
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 {
|
||||
guard let avatar = notification.account.avatar else { continue }
|
||||
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
|
||||
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
|
||||
|
||||
let ownAccount: Account
|
||||
|
|
|
@ -48,7 +48,7 @@ struct PreferencesView: View {
|
|||
indices.remove(index)
|
||||
}
|
||||
|
||||
localData.accounts.remove(atOffsets: indices)
|
||||
indices.forEach { localData.removeAccount(localData.accounts[$0]) }
|
||||
|
||||
if logoutFromCurrent {
|
||||
self.logoutPressed()
|
||||
|
|
|
@ -60,15 +60,18 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
|
|||
// MARK: - DiffableTimelineLikeTableViewController
|
||||
|
||||
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
|
||||
|
||||
cell.delegate = self
|
||||
// todo: dataSource.sectionIdentifier is only available on iOS 15
|
||||
cell.showPinned = dataSource.snapshot().indexOfSection(.pinned) == indexPath.section
|
||||
cell.updateUI(statusID: item.id, state: item.state)
|
||||
|
||||
cell.showPinned = pinned
|
||||
cell.updateUI(statusID: id, state: state)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
override func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
|
||||
guard accountID != nil else {
|
||||
|
@ -85,16 +88,16 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
|
|||
case let .failure(error):
|
||||
completion(.failure(.client(error)))
|
||||
|
||||
case let .success(statuses, _):
|
||||
case let .success(statuses, pagination):
|
||||
if !statuses.isEmpty {
|
||||
self.newer = .after(id: statuses.first!.id, count: nil)
|
||||
self.older = .before(id: statuses.last!.id, count: nil)
|
||||
self.newer = pagination?.newer ?? .after(id: statuses.first!.id, count: nil)
|
||||
self.older = pagination?.older ?? .before(id: statuses.last!.id, count: nil)
|
||||
}
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
DispatchQueue.main.async {
|
||||
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 {
|
||||
self.loadPinnedStatuses(snapshot: { snapshot }, completion: completion)
|
||||
} else {
|
||||
|
@ -122,7 +125,7 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
|
|||
DispatchQueue.main.async {
|
||||
var snapshot = snapshot()
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
@ -141,17 +144,17 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
|
|||
case let .failure(error):
|
||||
completion(.failure(.client(error)))
|
||||
|
||||
case let .success(statuses, _):
|
||||
case let .success(statuses, pagination):
|
||||
guard !statuses.isEmpty else {
|
||||
completion(.failure(.noOlder))
|
||||
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) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
@ -170,17 +173,17 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
|
|||
case let .failure(error):
|
||||
completion(.failure(.client(error)))
|
||||
|
||||
case let .success(statuses, _):
|
||||
case let .success(statuses, pagination):
|
||||
guard !statuses.isEmpty else {
|
||||
completion(.failure(.allCaughtUp))
|
||||
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) {
|
||||
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 {
|
||||
snapshot.insertItems(items, beforeItem: first)
|
||||
} else {
|
||||
|
@ -239,22 +242,22 @@ extension ProfileStatusesViewController {
|
|||
}
|
||||
|
||||
extension ProfileStatusesViewController {
|
||||
enum Section: CaseIterable {
|
||||
enum Section: DiffableTimelineLikeSection {
|
||||
case loadingIndicator
|
||||
case pinned
|
||||
case statuses
|
||||
}
|
||||
struct Item: Hashable {
|
||||
let id: String
|
||||
let state: StatusState
|
||||
let pinned: Bool
|
||||
enum Item: DiffableTimelineLikeItem {
|
||||
case loadingIndicator
|
||||
case status(id: String, state: StatusState, pinned: Bool)
|
||||
|
||||
static func ==(lhs: Item, rhs: Item) -> Bool {
|
||||
return lhs.id == rhs.id && lhs.pinned == rhs.pinned
|
||||
var id: String? {
|
||||
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? {
|
||||
switch item {
|
||||
case .loadingIndicator:
|
||||
return self.loadingIndicatorCell(indexPath: indexPath)
|
||||
|
||||
case let .status(id: id, state: state):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell
|
||||
|
||||
|
@ -139,15 +142,18 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
|
|||
case let .failure(error):
|
||||
completion(.failure(.client(error)))
|
||||
|
||||
case let .success(statuses, _):
|
||||
case let .success(statuses, pagination):
|
||||
if !statuses.isEmpty {
|
||||
self.newer = .after(id: statuses.first!.id, count: nil)
|
||||
self.older = .before(id: statuses.last!.id, count: nil)
|
||||
self.newer = pagination?.newer ?? .after(id: statuses.first!.id, count: nil)
|
||||
self.older = pagination?.older ?? .before(id: statuses.last!.id, count: nil)
|
||||
}
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
DispatchQueue.main.async {
|
||||
var snapshot = self.dataSource.snapshot()
|
||||
if snapshot.sectionIdentifiers.contains(.loadingIndicator) {
|
||||
snapshot.deleteSections([.loadingIndicator])
|
||||
}
|
||||
snapshot.deleteSections([.statuses, .footer])
|
||||
snapshot.appendSections([.statuses, .footer])
|
||||
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown) }, toSection: .statuses)
|
||||
|
@ -183,9 +189,9 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
|
|||
case let .failure(error):
|
||||
completion(.failure(.client(error)))
|
||||
|
||||
case let .success(statuses, _):
|
||||
case let .success(statuses, pagination):
|
||||
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) {
|
||||
|
@ -210,13 +216,13 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
|
|||
case let .failure(error):
|
||||
completion(.failure(.client(error)))
|
||||
|
||||
case let .success(statuses, _):
|
||||
case let .success(statuses, pagination):
|
||||
guard !statuses.isEmpty else {
|
||||
completion(.failure(.allCaughtUp))
|
||||
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) {
|
||||
var snapshot = currentSnapshot()
|
||||
|
@ -245,12 +251,14 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
|
|||
}
|
||||
|
||||
extension TimelineTableViewController {
|
||||
enum Section: Hashable, CaseIterable {
|
||||
enum Section: DiffableTimelineLikeSection {
|
||||
case loadingIndicator
|
||||
case header
|
||||
case statuses
|
||||
case footer
|
||||
}
|
||||
enum Item: Hashable {
|
||||
enum Item: DiffableTimelineLikeItem {
|
||||
case loadingIndicator
|
||||
case status(id: String, state: StatusState)
|
||||
case confirmLoadMore
|
||||
case publicTimelineDescription(local: Bool)
|
||||
|
@ -270,13 +278,15 @@ extension TimelineTableViewController {
|
|||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case let .status(id: id, state: _):
|
||||
case .loadingIndicator:
|
||||
hasher.combine(0)
|
||||
case let .status(id: id, state: _):
|
||||
hasher.combine(1)
|
||||
hasher.combine(id)
|
||||
case .confirmLoadMore:
|
||||
hasher.combine(1)
|
||||
case let .publicTimelineDescription(local: local):
|
||||
hasher.combine(2)
|
||||
case let .publicTimelineDescription(local: local):
|
||||
hasher.combine(3)
|
||||
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 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 LoadResult = Result<Snapshot, LoadError>
|
||||
|
@ -40,6 +47,7 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
|
|||
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 140
|
||||
tableView.register(LoadingTableViewCell.self, forCellReuseIdentifier: "loadingCell")
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
self.refreshControl = UIRefreshControl()
|
||||
|
@ -104,15 +112,34 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
|
|||
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() {
|
||||
guard state == .unloaded else { return }
|
||||
// set loaded immediately so we don't trigger another request while the current one is running
|
||||
state = .loadingInitial
|
||||
|
||||
let showIndicator = showLoadingIndicatorDelayed()
|
||||
|
||||
loadInitialItems() { result in
|
||||
DispatchQueue.main.async {
|
||||
showIndicator.cancel()
|
||||
|
||||
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.state = .loaded
|
||||
|
||||
|
@ -137,16 +164,22 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
|
|||
}
|
||||
|
||||
func loadOlder() {
|
||||
guard state != .loadingOlder else { return }
|
||||
guard state == .loaded else { return }
|
||||
|
||||
state = .loadingOlder
|
||||
|
||||
let showIndicator = showLoadingIndicatorDelayed()
|
||||
|
||||
loadOlderItems(currentSnapshot: dataSource.snapshot) { result in
|
||||
DispatchQueue.main.async {
|
||||
self.state = .loaded
|
||||
showIndicator.cancel()
|
||||
|
||||
switch result {
|
||||
case let .success(snapshot):
|
||||
case .success(var snapshot):
|
||||
if snapshot.sectionIdentifiers.contains(.loadingIndicator) {
|
||||
snapshot.deleteSections([.loadingIndicator])
|
||||
}
|
||||
self.dataSource.apply(snapshot, animatingDifferences: false)
|
||||
|
||||
case let .failure(.client(error)):
|
||||
|
@ -263,6 +296,12 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
|
|||
|
||||
// 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? {
|
||||
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
|
||||
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
|
||||
switch response {
|
||||
case .success(let status, _):
|
||||
|
|
|
@ -13,11 +13,13 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
|
|||
let titles: [String]
|
||||
let pageControllers: [UIViewController]
|
||||
|
||||
private(set) var currentIndex: Int!
|
||||
private(set) var currentIndex = 0
|
||||
|
||||
var segmentedControl: UISegmentedControl!
|
||||
|
||||
init(titles: [String], pageControllers: [UIViewController]) {
|
||||
precondition(!pageControllers.isEmpty)
|
||||
|
||||
self.titles = titles
|
||||
self.pageControllers = pageControllers
|
||||
|
||||
|
@ -41,7 +43,6 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
|
|||
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
segmentedControl.selectedSegmentIndex = 0
|
||||
selectPage(at: 0, animated: false)
|
||||
|
||||
addKeyCommand(MenuController.prevSubTabCommand)
|
||||
|
@ -56,7 +57,7 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
|
|||
}
|
||||
|
||||
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)
|
||||
navigationItem.title = pageControllers[index].title
|
||||
currentIndex = index
|
||||
|
|
|
@ -67,6 +67,17 @@ class SplitNavigationController: UIViewController {
|
|||
if let 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) {
|
||||
|
@ -76,10 +87,10 @@ class SplitNavigationController: UIViewController {
|
|||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
embedChild(rootNav, layout: false)
|
||||
embedChild(secondaryNav, layout: false)
|
||||
rootNav.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(rootNav.view)
|
||||
secondaryNav.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(secondaryNav.view)
|
||||
|
||||
separatorView.backgroundColor = .separator
|
||||
separatorView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
|
|
@ -17,4 +17,5 @@ struct ViewTags {
|
|||
static let navForwardBarButton = 42004
|
||||
static let navEmptyTitleView = 42005
|
||||
static let splitNavCloseSecondaryButton = 42006
|
||||
static let customAlertSeparator = 42007
|
||||
}
|
||||
|
|
|
@ -12,13 +12,13 @@ import WebURLFoundationExtras
|
|||
|
||||
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
||||
|
||||
struct AccountDisplayNameLabel<Account: AccountProtocol>: View {
|
||||
let account: Account
|
||||
struct AccountDisplayNameLabel: View {
|
||||
let account: any AccountProtocol
|
||||
let fontSize: Int
|
||||
@State var text: Text
|
||||
@State var emojiRequests = [ImageCache.Request]()
|
||||
|
||||
init(account: Account, fontSize: Int) {
|
||||
init(account: any AccountProtocol, fontSize: Int) {
|
||||
self.account = account
|
||||
self.fontSize = fontSize
|
||||
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)
|
||||
guard !matches.isEmpty else { return }
|
||||
|
||||
let emojiImages = MultiThreadDictionary<String, Image>(name: "AcccountDisplayNameLabel Emoji Images")
|
||||
let emojiImages = MultiThreadDictionary<String, Image>()
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
|
|
|
@ -24,6 +24,7 @@ extension BaseEmojiLabel {
|
|||
// blergh
|
||||
precondition(Thread.isMainThread)
|
||||
|
||||
emojiIdentifier = identifier
|
||||
emojiRequests.forEach { $0.cancel() }
|
||||
emojiRequests = []
|
||||
|
||||
|
@ -38,10 +39,7 @@ extension BaseEmojiLabel {
|
|||
return
|
||||
}
|
||||
|
||||
// not using a MultiThreadDictionary so that cached images can be added immediately
|
||||
// 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] = [:]
|
||||
let emojiImages = MultiThreadDictionary<String, UIImage>()
|
||||
var foundEmojis = false
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
@ -71,13 +69,9 @@ extension BaseEmojiLabel {
|
|||
group.leave()
|
||||
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
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
if let request = request {
|
||||
emojiRequests.append(request)
|
||||
}
|
||||
|
@ -92,6 +86,11 @@ extension BaseEmojiLabel {
|
|||
func buildStringWithEmojisReplaced(usePlaceholders: Bool) -> NSAttributedString {
|
||||
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
|
||||
for match in matches.reversed() {
|
||||
let shortcode = (attributedString.string as NSString).substring(with: match.range(at: 1))
|
||||
|
@ -108,6 +107,7 @@ extension BaseEmojiLabel {
|
|||
let attachmentStr = NSAttributedString(attachment: attachment)
|
||||
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
|
||||
}
|
||||
}
|
||||
|
||||
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 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
|
||||
|
||||
|
@ -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.
|
||||
private weak var currentTargetedPreview: UITargetedPreview?
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
override init(frame: CGRect, textContainer: NSTextContainer?) {
|
||||
super.init(frame: frame, textContainer: textContainer)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
private func commonInit() {
|
||||
delegate = self
|
||||
|
||||
// Disable layer masking, otherwise the context menu opening animation
|
||||
|
@ -57,8 +71,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
|||
}
|
||||
|
||||
// MARK: - Emojis
|
||||
func setEmojis(_ emojis: [Emoji]) {
|
||||
replaceEmojis(in: attributedText!, emojis: emojis, identifier: emojiIdentifier) { attributedString, didReplaceEmojis in
|
||||
func setEmojis(_ emojis: [Emoji], identifier: String?) {
|
||||
replaceEmojis(in: attributedText!, emojis: emojis, identifier: identifier) { attributedString, didReplaceEmojis in
|
||||
guard didReplaceEmojis else {
|
||||
return
|
||||
}
|
||||
|
@ -78,10 +92,7 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
|||
mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines)
|
||||
mutAttrString.collapseWhitespace()
|
||||
|
||||
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
|
||||
mutAttrString.addAttribute(.paragraphStyle, value: style, range: mutAttrString.fullRange)
|
||||
mutAttrString.addAttribute(.paragraphStyle, value: paragraphStyle, range: mutAttrString.fullRange)
|
||||
|
||||
self.attributedText = mutAttrString
|
||||
}
|
||||
|
@ -197,13 +208,17 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
|||
let locationInTextContainer = CGPoint(x: point.x - textContainerInset.left, y: point.y - textContainerInset.top)
|
||||
if #available(iOS 16.0, *),
|
||||
let textLayoutManager {
|
||||
guard let fragment = textLayoutManager.textLayoutFragment(for: point),
|
||||
let lineFragment = fragment.textLineFragments.first(where: { lineFragment in
|
||||
lineFragment.typographicBounds.offsetBy(dx: fragment.layoutFragmentFrame.minX, dy: fragment.layoutFragmentFrame.minY).contains(point)
|
||||
guard let fragment = textLayoutManager.textLayoutFragment(for: locationInTextContainer) else {
|
||||
return nil
|
||||
}
|
||||
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 {
|
||||
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()
|
||||
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
|
||||
}
|
||||
// .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)
|
||||
return true
|
||||
}
|
||||
|
|
|
@ -21,13 +21,9 @@ class EmojiLabel: UILabel, BaseEmojiLabel {
|
|||
func setEmojis(_ emojis: [Emoji], identifier: String) {
|
||||
guard emojis.count > 0, let attributedText = attributedText else { return }
|
||||
|
||||
self.emojiIdentifier = identifier
|
||||
emojiRequests.forEach { $0.cancel() }
|
||||
emojiRequests = []
|
||||
hasEmojis = true
|
||||
|
||||
replaceEmojis(in: attributedText.string, emojis: emojis, identifier: identifier) { [weak self] (newAttributedText, _) in
|
||||
replaceEmojis(in: attributedText.string, emojis: emojis, identifier: identifier) { [weak self] (newAttributedText, didReplaceEmojis) in
|
||||
guard let self = self, self.emojiIdentifier == identifier else { return }
|
||||
self.hasEmojis = didReplaceEmojis
|
||||
self.attributedText = newAttributedText
|
||||
self.setNeedsLayout()
|
||||
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.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
|
||||
if accountID != mastodonController.account?.id {
|
||||
|
@ -148,7 +148,7 @@ class ProfileHeaderView: UIView {
|
|||
valueTextView.isSelectable = false
|
||||
valueTextView.font = .systemFont(ofSize: 17)
|
||||
valueTextView.setTextFromHtml(field.value)
|
||||
valueTextView.setEmojis(account.emojis)
|
||||
valueTextView.setEmojis(account.emojis, identifier: account.id)
|
||||
valueTextView.textAlignment = .left
|
||||
valueTextView.awakeFromNib()
|
||||
valueTextView.navigationDelegate = delegate
|
||||
|
|
|
@ -412,25 +412,52 @@ class BaseStatusTableViewCell: UITableViewCell {
|
|||
// if we are about to reblog and the user has confirmation enabled
|
||||
if !reblogged,
|
||||
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)
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
|
||||
alert.addAction(UIAlertAction(title: "Reblog", style: .default) { (_) in
|
||||
self.toggleReblogInternal()
|
||||
})
|
||||
let image: UIImage?
|
||||
let reblogVisibilityActions: [CustomAlertController.MenuAction]?
|
||||
if mastodonController.instanceFeatures.reblogVisibility {
|
||||
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)
|
||||
} 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!)") }
|
||||
|
||||
let oldValue = reblogged
|
||||
reblogged = !reblogged
|
||||
|
||||
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
|
||||
DispatchQueue.main.async {
|
||||
if case let .success(newStatus, _) = response {
|
||||
|
|
|
@ -20,6 +20,7 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
|
|||
}()
|
||||
|
||||
@IBOutlet weak var reblogLabel: EmojiLabel!
|
||||
@IBOutlet weak var reblogSpacer: UIView!
|
||||
@IBOutlet weak var timestampLabel: UILabel!
|
||||
@IBOutlet weak var pinImageView: UIImageView!
|
||||
@IBOutlet weak var actionsContainerView: UIView!
|
||||
|
@ -82,6 +83,7 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
|
|||
reblogStatusID = statusID
|
||||
rebloggerID = status.account.id
|
||||
reblogLabel.isHidden = false
|
||||
reblogSpacer.isHidden = false
|
||||
updateRebloggerLabel(reblogger: status.account)
|
||||
|
||||
status = rebloggedStatus
|
||||
|
@ -91,6 +93,7 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
|
|||
reblogStatusID = nil
|
||||
rebloggerID = nil
|
||||
reblogLabel.isHidden = true
|
||||
reblogSpacer.isHidden = true
|
||||
}
|
||||
|
||||
super.doUpdateUI(status: status, state: state)
|
||||
|
@ -131,9 +134,13 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
|
|||
let oldState = actionsContainerView.isHidden
|
||||
if oldState != Preferences.shared.hideActionsInTimeline {
|
||||
updateActionsVisibility()
|
||||
if #available(iOS 16.0, *) {
|
||||
invalidateIntrinsicContentSize()
|
||||
} else {
|
||||
// not really accurate, but it notifies the vc our height has changed
|
||||
delegate?.statusCellCollapsedStateChanged(self)
|
||||
}
|
||||
}
|
||||
|
||||
super.updateStatusIconsForPreferences(status)
|
||||
}
|
||||
|
@ -141,16 +148,8 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
|
|||
private func updateActionsVisibility() {
|
||||
if Preferences.shared.hideActionsInTimeline {
|
||||
actionsContainerView.isHidden = true
|
||||
actionsContainerHeightConstraint.isActive = false
|
||||
verticalStackToSuperviewConstraint.isActive = true
|
||||
verticalStackToActionsContainerConstraint.isActive = false
|
||||
} else {
|
||||
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"?>
|
||||
<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"/>
|
||||
<dependencies>
|
||||
<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="System colors in document resources" minToolsVersion="11.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"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<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"/>
|
||||
<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">
|
||||
<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"/>
|
||||
<color key="textColor" systemColor="secondaryLabelColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</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">
|
||||
<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>
|
||||
<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"/>
|
||||
|
@ -109,36 +116,30 @@
|
|||
</connections>
|
||||
</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">
|
||||
<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>
|
||||
<color key="textColor" systemColor="labelColor"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
||||
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
||||
</textView>
|
||||
<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"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" priority="999" constant="65" id="khY-jm-CPn"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<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"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" secondItem="nbq-yr-2mA" secondAttribute="width" multiplier="9:16" priority="999" id="Rvt-zs-fkd"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<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"/>
|
||||
</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>
|
||||
</stackView>
|
||||
<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"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="gIY-Wp-RSk" firstAttribute="leading" secondItem="QMP-j2-HLn" secondAttribute="trailing" constant="8" id="0Tm-v7-Ts4"/>
|
||||
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="QMP-j2-HLn" secondAttribute="bottom" constant="8" id="2Ao-Gj-fY3"/>
|
||||
<constraint 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">
|
||||
<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>
|
||||
<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"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="18"/>
|
||||
<state key="normal" image="arrowshape.turn.up.left.fill" catalog="system"/>
|
||||
|
@ -161,7 +184,7 @@
|
|||
</connections>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="6tW-z8-Qh9">
|
||||
<rect key="frame" x="167.5" y="0.0" width="84" height="26"/>
|
||||
<rect key="frame" x="171.5" y="0.0" width="86" height="26"/>
|
||||
<accessibility key="accessibilityConfiguration" label="Reblog"/>
|
||||
<state key="normal" image="repeat" catalog="system"/>
|
||||
<connections>
|
||||
|
@ -169,7 +192,7 @@
|
|||
</connections>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="982-J4-NGl">
|
||||
<rect key="frame" x="251.5" y="0.0" width="83.5" height="26"/>
|
||||
<rect key="frame" x="257.5" y="0.0" width="85.5" height="26"/>
|
||||
<accessibility key="accessibilityConfiguration" label="More Actions"/>
|
||||
<state key="normal" image="ellipsis" catalog="system"/>
|
||||
<connections>
|
||||
|
@ -177,7 +200,7 @@
|
|||
</connections>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="x0t-TR-jJ4">
|
||||
<rect key="frame" x="84" y="0.0" width="83.5" height="26"/>
|
||||
<rect key="frame" x="86" y="0.0" width="85.5" height="26"/>
|
||||
<accessibility key="accessibilityConfiguration" label="Favorite"/>
|
||||
<state key="normal" image="star.fill" catalog="system"/>
|
||||
<connections>
|
||||
|
@ -206,31 +229,6 @@
|
|||
</constraints>
|
||||
</view>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="gIY-Wp-RSk" firstAttribute="leading" secondItem="QMP-j2-HLn" secondAttribute="trailing" constant="8" id="0Tm-v7-Ts4"/>
|
||||
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="QMP-j2-HLn" secondAttribute="bottom" constant="8" id="2Ao-Gj-fY3"/>
|
||||
<constraint firstItem="TUP-Nz-5Yh" firstAttribute="trailing" secondItem="ve3-Y1-NQH" secondAttribute="trailingMargin" id="3l0-tE-Ak1"/>
|
||||
<constraint firstItem="TUP-Nz-5Yh" firstAttribute="top" secondItem="gIY-Wp-RSk" secondAttribute="bottom" 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>
|
||||
<constraint firstItem="ve3-Y1-NQH" firstAttribute="width" secondItem="yNh-ac-v6c" secondAttribute="width" id="xN6-cs-Tnn"/>
|
||||
</constraints>
|
||||
|
@ -262,10 +260,10 @@
|
|||
<outlet property="pollView" destination="x3b-Zl-9F0" id="WIF-Oz-cnm"/>
|
||||
<outlet property="reblogButton" destination="6tW-z8-Qh9" id="u2t-8D-kOn"/>
|
||||
<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="timestampLabel" destination="35d-EA-ReR" id="Ny2-nV-nqP"/>
|
||||
<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"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="29.600000000000001" y="79.160419790104953"/>
|
||||
|
|
|
@ -16,9 +16,8 @@ class StatusContentTextView: ContentTextView {
|
|||
|
||||
func setTextFrom(status: StatusMO) {
|
||||
statusID = status.id
|
||||
emojiIdentifier = status.id
|
||||
setTextFromHtml(status.content)
|
||||
setEmojis(status.emojis)
|
||||
setEmojis(status.emojis, identifier: status.id)
|
||||
}
|
||||
|
||||
override func getMention(for url: URL, text: String) -> Mention? {
|
||||
|
|
|
@ -202,7 +202,7 @@ struct XCBActions {
|
|||
}
|
||||
|
||||
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?) {
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<key>poll votes count</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%2$#@votes@</string>
|
||||
<string>%#@votes@</string>
|
||||
<key>votes</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
|
|
Loading…
Reference in New Issue