Compare commits

...

25 Commits

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

View File

@ -1,5 +1,18 @@
# Changelog
## 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

View File

@ -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 }

View File

@ -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> {

View File

@ -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" */;

View File

@ -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

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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 lock.withLock { dict in
dict.keys.contains(key)
}
return value
}
func withLock<R>(_ body: @Sendable (inout [Key: Value]) throws -> R) rethrows -> R where R: Sendable {
return try lock.withLock(body)
}
}
// TODO: replace this only with OSAllocatedUnfairLock
@available(iOS, obsoleted: 16.0)
fileprivate protocol Lock<State> {
associatedtype State
func withLock<R>(_ body: @Sendable (inout State) throws -> R) rethrows -> R where R: Sendable
}
@available(iOS 16.0, *)
extension OSAllocatedUnfairLock: Lock {
}
// from http://www.russbishop.net/the-law
fileprivate class UnfairLock<State>: Lock {
private var lock: UnsafeMutablePointer<os_unfair_lock>
private var state: State
init(initialState: State) {
self.state = initialState
self.lock = .allocate(capacity: 1)
self.lock.initialize(to: os_unfair_lock())
}
deinit {
self.lock.deallocate()
}
func withLock<R>(_ body: (inout State) throws -> R) rethrows -> R where R: Sendable {
os_unfair_lock_lock(lock)
defer { os_unfair_lock_unlock(lock) }
return try body(&state)
}
}

View File

@ -48,7 +48,7 @@ struct ComposeAutocompleteMentionsView: View {
@ObservedObject private var preferences = Preferences.shared
// 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)
.foregroundColor(Color(UIColor.label))
case let .coreData(underlying):
AccountDisplayNameLabel(account: underlying, fontSize: 14)
.foregroundColor(Color(UIColor.label))
}
AccountDisplayNameLabel(account: account.value, 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,30 +254,41 @@ struct ComposeAutocompleteEmojisView: View {
private var toggleExpandedButton: some View {
Button {
expanded.toggle()
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 =
emojis.map { (emoji) -> (Emoji, (matched: Bool, score: Int)) in
(emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode))
}
.filter(\.1.matched)
.sorted { $0.1.score > $1.1.score }
.map(\.0)
var emojis = emojis
if !query.isEmpty {
emojis =
emojis.map { (emoji) -> (Emoji, (matched: Bool, score: Int)) in
(emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode))
}
.filter(\.1.matched)
.sorted { $0.1.score > $1.1.score }
.map(\.0)
}
var shortcodes = Set<String>()
self.emojis = []
for emoji in emojis where !shortcodes.contains(emoji.shortcode) {
self.emojis.append(emoji)
shortcodes.insert(emoji.shortcode)
}
}
}
}

View File

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

View File

@ -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,23 +37,25 @@ struct ComposeReplyView: View {
Spacer()
}
ComposeReplyContentView(status: status) { (newHeight) in
self.contentHeight = newHeight
.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
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()

View File

@ -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)
}
.scrollDismissesKeyboardInteractivelyIfAvailable()
ScrollView(.vertical) {
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
)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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
}
}

View File

@ -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>

View File

@ -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
}
}
}

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -60,14 +60,17 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
// MARK: - DiffableTimelineLikeTableViewController
override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? {
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)
return cell
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
cell.showPinned = pinned
cell.updateUI(statusID: id, state: state)
return cell
}
}
override func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
@ -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
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(pinned)
var id: String? {
switch self {
case .loadingIndicator:
return nil
case .status(id: let id, state: _, pinned: _):
return id
}
}
}
}

View File

@ -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)
}
}

View File

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

View File

@ -9,7 +9,14 @@
import UIKit
import 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,25 +164,31 @@ 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)):
let config = ToastConfiguration(from: error, with: "Error Loading Older", in: self) { [weak self] (toast) in
toast.dismissToast(animated: true)
self?.loadOlder()
}
self.showToast(configuration: config, animated: true)
default:
break
}
@ -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")
}

View File

@ -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, _):

View File

@ -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

View File

@ -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

View File

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

View File

@ -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()

View File

@ -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,12 +69,8 @@ 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()
}
emojiImages[emoji.shortcode] = transformedImage
group.leave()
}
if let request = request {
emojiRequests.append(request)
@ -92,21 +86,27 @@ extension BaseEmojiLabel {
func buildStringWithEmojisReplaced(usePlaceholders: Bool) -> NSAttributedString {
let mutAttrString = NSMutableAttributedString(attributedString: attributedString)
// 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))
let attachment: NSTextAttachment
if let emojiImage = emojiImages[shortcode] {
attachment = NSTextAttachment(emojiImage: emojiImage, in: self.emojiFont, with: self.emojiTextColor)
} else if usePlaceholders {
attachment = NSTextAttachment(emojiPlaceholderIn: self.emojiFont)
} else {
continue
// 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))
let attachment: NSTextAttachment
if let emojiImage = emojiImages[shortcode] {
attachment = NSTextAttachment(emojiImage: emojiImage, in: self.emojiFont, with: self.emojiTextColor)
} else if usePlaceholders {
attachment = NSTextAttachment(emojiPlaceholderIn: self.emojiFont)
} else {
continue
}
let attachmentStr = NSAttributedString(attachment: attachment)
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
}
let attachmentStr = NSAttributedString(attachment: attachment)
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
}
return mutAttrString

View File

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

View File

@ -23,6 +23,12 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
var defaultFont: UIFont = .systemFont(ofSize: 17)
var 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
}

View File

@ -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()

View File

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

View File

@ -110,7 +110,7 @@ class ProfileHeaderView: UIView {
noteTextView.navigationDelegate = delegate
noteTextView.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

View File

@ -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 {

View File

@ -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,8 +134,12 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
let oldState = actionsContainerView.isHidden
if oldState != Preferences.shared.hideActionsInTimeline {
updateActionsVisibility()
// not really accurate, but it notifies the vc our height has changed
delegate?.statusCellCollapsedStateChanged(self)
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
}
}

View File

@ -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,78 +149,18 @@
<constraint firstAttribute="height" constant="22" placeholder="YES" id="ipd-WE-P20"/>
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="TUP-Nz-5Yh">
<rect key="frame" x="0.0" y="169.5" width="335" height="26"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="rKF-yF-KIa">
<rect key="frame" x="0.0" y="0.0" width="84" height="26"/>
<accessibility key="accessibilityConfiguration" label="Reply"/>
<fontDescription key="fontDescription" type="system" pointSize="18"/>
<state key="normal" image="arrowshape.turn.up.left.fill" catalog="system"/>
<connections>
<action selector="replyPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="ybz-3W-jAa"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="6tW-z8-Qh9">
<rect key="frame" x="167.5" y="0.0" width="84" height="26"/>
<accessibility key="accessibilityConfiguration" label="Reblog"/>
<state key="normal" image="repeat" catalog="system"/>
<connections>
<action selector="reblogPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="Wa2-ZA-TBo"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="982-J4-NGl">
<rect key="frame" x="251.5" y="0.0" width="83.5" height="26"/>
<accessibility key="accessibilityConfiguration" label="More Actions"/>
<state key="normal" image="ellipsis" catalog="system"/>
<connections>
<action selector="morePressed" destination="iN0-l3-epB" eventType="touchUpInside" id="WT4-fi-usq"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="x0t-TR-jJ4">
<rect key="frame" x="84" y="0.0" width="83.5" height="26"/>
<accessibility key="accessibilityConfiguration" label="Favorite"/>
<state key="normal" image="star.fill" catalog="system"/>
<connections>
<action selector="favoritePressed" destination="iN0-l3-epB" eventType="touchUpInside" id="8Q8-Rz-k02"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstAttribute="height" constant="26" id="1FK-Er-G11"/>
<constraint firstAttribute="bottom" secondItem="rKF-yF-KIa" secondAttribute="bottom" id="KyG-2C-MgN"/>
<constraint firstItem="x0t-TR-jJ4" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="L3w-JH-eeG"/>
<constraint firstItem="6tW-z8-Qh9" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="N7j-f4-gvP"/>
<constraint firstItem="982-J4-NGl" firstAttribute="leading" secondItem="6tW-z8-Qh9" secondAttribute="trailing" id="VQo-DJ-C7L"/>
<constraint firstItem="982-J4-NGl" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="W53-1a-fKu"/>
<constraint firstItem="x0t-TR-jJ4" firstAttribute="leading" secondItem="rKF-yF-KIa" secondAttribute="trailing" id="WPd-A2-6Ju"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="width" secondItem="x0t-TR-jJ4" secondAttribute="width" id="X7m-pJ-oje"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="leading" secondItem="TUP-Nz-5Yh" secondAttribute="leading" placeholder="YES" id="aFR-Ew-99S"/>
<constraint firstAttribute="bottom" secondItem="982-J4-NGl" secondAttribute="bottom" id="eXy-3h-51w"/>
<constraint firstAttribute="bottom" secondItem="x0t-TR-jJ4" secondAttribute="bottom" id="euN-Nf-rwh"/>
<constraint firstItem="6tW-z8-Qh9" firstAttribute="leading" secondItem="x0t-TR-jJ4" secondAttribute="trailing" id="oAK-VG-bbp"/>
<constraint firstAttribute="bottom" secondItem="6tW-z8-Qh9" secondAttribute="bottom" id="tpf-Q3-V3l"/>
<constraint firstAttribute="trailing" secondItem="982-J4-NGl" secondAttribute="trailing" id="uQG-FZ-F7u"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="width" secondItem="982-J4-NGl" secondAttribute="width" id="vir-iq-biv"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="width" secondItem="6tW-z8-Qh9" secondAttribute="width" id="vqw-d7-VtZ"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="wWH-J7-egM"/>
</constraints>
</view>
</subviews>
<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 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="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"/>
@ -230,6 +171,63 @@
</mask>
</variation>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="TUP-Nz-5Yh">
<rect key="frame" x="0.0" y="198" width="343" height="26"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="rKF-yF-KIa">
<rect key="frame" x="0.0" y="0.0" width="86" height="26"/>
<accessibility key="accessibilityConfiguration" label="Reply"/>
<fontDescription key="fontDescription" type="system" pointSize="18"/>
<state key="normal" image="arrowshape.turn.up.left.fill" catalog="system"/>
<connections>
<action selector="replyPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="ybz-3W-jAa"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="6tW-z8-Qh9">
<rect key="frame" x="171.5" y="0.0" width="86" height="26"/>
<accessibility key="accessibilityConfiguration" label="Reblog"/>
<state key="normal" image="repeat" catalog="system"/>
<connections>
<action selector="reblogPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="Wa2-ZA-TBo"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="982-J4-NGl">
<rect key="frame" x="257.5" y="0.0" width="85.5" height="26"/>
<accessibility key="accessibilityConfiguration" label="More Actions"/>
<state key="normal" image="ellipsis" catalog="system"/>
<connections>
<action selector="morePressed" destination="iN0-l3-epB" eventType="touchUpInside" id="WT4-fi-usq"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="x0t-TR-jJ4">
<rect key="frame" x="86" y="0.0" width="85.5" height="26"/>
<accessibility key="accessibilityConfiguration" label="Favorite"/>
<state key="normal" image="star.fill" catalog="system"/>
<connections>
<action selector="favoritePressed" destination="iN0-l3-epB" eventType="touchUpInside" id="8Q8-Rz-k02"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstAttribute="height" constant="26" id="1FK-Er-G11"/>
<constraint firstAttribute="bottom" secondItem="rKF-yF-KIa" secondAttribute="bottom" id="KyG-2C-MgN"/>
<constraint firstItem="x0t-TR-jJ4" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="L3w-JH-eeG"/>
<constraint firstItem="6tW-z8-Qh9" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="N7j-f4-gvP"/>
<constraint firstItem="982-J4-NGl" firstAttribute="leading" secondItem="6tW-z8-Qh9" secondAttribute="trailing" id="VQo-DJ-C7L"/>
<constraint firstItem="982-J4-NGl" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="W53-1a-fKu"/>
<constraint firstItem="x0t-TR-jJ4" firstAttribute="leading" secondItem="rKF-yF-KIa" secondAttribute="trailing" id="WPd-A2-6Ju"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="width" secondItem="x0t-TR-jJ4" secondAttribute="width" id="X7m-pJ-oje"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="leading" secondItem="TUP-Nz-5Yh" secondAttribute="leading" placeholder="YES" id="aFR-Ew-99S"/>
<constraint firstAttribute="bottom" secondItem="982-J4-NGl" secondAttribute="bottom" id="eXy-3h-51w"/>
<constraint firstAttribute="bottom" secondItem="x0t-TR-jJ4" secondAttribute="bottom" id="euN-Nf-rwh"/>
<constraint firstItem="6tW-z8-Qh9" firstAttribute="leading" secondItem="x0t-TR-jJ4" secondAttribute="trailing" id="oAK-VG-bbp"/>
<constraint firstAttribute="bottom" secondItem="6tW-z8-Qh9" secondAttribute="bottom" id="tpf-Q3-V3l"/>
<constraint firstAttribute="trailing" secondItem="982-J4-NGl" secondAttribute="trailing" id="uQG-FZ-F7u"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="width" secondItem="982-J4-NGl" secondAttribute="width" id="vir-iq-biv"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="width" secondItem="6tW-z8-Qh9" secondAttribute="width" id="vqw-d7-VtZ"/>
<constraint firstItem="rKF-yF-KIa" firstAttribute="top" secondItem="TUP-Nz-5Yh" secondAttribute="top" id="wWH-J7-egM"/>
</constraints>
</view>
</subviews>
<constraints>
<constraint firstItem="ve3-Y1-NQH" firstAttribute="width" secondItem="yNh-ac-v6c" secondAttribute="width" id="xN6-cs-Tnn"/>
@ -262,10 +260,10 @@
<outlet property="pollView" destination="x3b-Zl-9F0" id="WIF-Oz-cnm"/>
<outlet property="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"/>

View File

@ -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? {

View File

@ -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?) {

View File

@ -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>