Compare commits
31 Commits
13d649bace
...
5ee140cdab
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 5ee140cdab | |
Shadowfacts | ff4dff1147 | |
Shadowfacts | ba1eed7a85 | |
Shadowfacts | 0c9f6e02bd | |
Shadowfacts | 565d17970f | |
Shadowfacts | dc3c2d027c | |
Shadowfacts | ba2c34fdd6 | |
Shadowfacts | 3691c3f483 | |
Shadowfacts | 9c103103e8 | |
Shadowfacts | 382d8ef2c8 | |
Shadowfacts | 2891f47cb3 | |
Shadowfacts | 3c80ec8b43 | |
Shadowfacts | 478ba3db28 | |
Shadowfacts | f96cd1b5e2 | |
Shadowfacts | 7f4ab57a1d | |
Shadowfacts | 8caf93bf0a | |
Shadowfacts | 9c4b68b09e | |
Shadowfacts | b49e8d0279 | |
Shadowfacts | 71a57e9859 | |
Shadowfacts | 081ef16e5e | |
Shadowfacts | b3ec259ce9 | |
Shadowfacts | 4f48514d1a | |
Shadowfacts | f96acd33f2 | |
Shadowfacts | cde061c77a | |
Shadowfacts | a79b3cfd70 | |
Shadowfacts | 9a35f96c75 | |
Shadowfacts | 60767c6a7e | |
Shadowfacts | 57668886b2 | |
Shadowfacts | ffb5c76f7c | |
Shadowfacts | 00e8dd6345 | |
Shadowfacts | 7904462920 |
28
CHANGELOG.md
28
CHANGELOG.md
|
@ -1,5 +1,33 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2022.1 (52)
|
||||||
|
Features/Improvements:
|
||||||
|
- Save and restore position for all timelines and all accounts
|
||||||
|
- As a side effect of this change, the first time you launch with this update, the timeline position will be lost
|
||||||
|
- Add preference to never blur attachments
|
||||||
|
- New segmented control for timeline switcher that scrolls when there's not enough space
|
||||||
|
- Copy status expand all setting when viewing More Replies in a converstaion
|
||||||
|
- Include followed hashtags in the Explore screen and iPad sidebar
|
||||||
|
- Allow saving or following hashtag from Add Hashtag screen
|
||||||
|
- Scroll long attachment descriptions in the gallery
|
||||||
|
- VoiceOver: Improve Profile Directory
|
||||||
|
- VoiceOver: Include attachment descriptions in timeline items when not marked as sensitive
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix swipe actions preference not persisting
|
||||||
|
- Fix rich text list bullets/numbers appearing black in dark mode
|
||||||
|
- Fix crash when previewing non-HTTP(S) link
|
||||||
|
- Fix images from Safari pasting as URLs rather than attachments
|
||||||
|
- Fix Trending Posts appearing to reload forever
|
||||||
|
- Fix controls reappearing when swiping between pages in the attachments gallery
|
||||||
|
- Fix reblog confirmation alert actions missing hover effect
|
||||||
|
- Fix status reply/visibility/local icons flashing blue when expanding a status
|
||||||
|
- iPad: Fix My Profile item sidebar item not updating when avatar style changes
|
||||||
|
- iPad: Fix tapping status bar not scrolling to top in single-column navigation mode
|
||||||
|
- VoiceOver: Fix crash when scrolling through local/federated timeline
|
||||||
|
- VoiceOver: Fix escape gesture not working in attachment gallery
|
||||||
|
- VoiceOver: Fix custom emoji not being stripped from display names
|
||||||
|
|
||||||
## 2022.1 (51)
|
## 2022.1 (51)
|
||||||
Features/Improvements:
|
Features/Improvements:
|
||||||
- Clarify text for conversation main status favorite/reblog count preference
|
- Clarify text for conversation main status favorite/reblog count preference
|
||||||
|
|
|
@ -304,6 +304,8 @@
|
||||||
D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */; };
|
D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */; };
|
||||||
D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */; };
|
D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */; };
|
||||||
D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; };
|
D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; };
|
||||||
|
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */; };
|
||||||
|
D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D706A62948D4D0000827ED /* TimlineState.swift */; };
|
||||||
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */; };
|
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */; };
|
||||||
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A44273D6C5700386A6C /* GIFImageView.swift */; };
|
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A44273D6C5700386A6C /* GIFImageView.swift */; };
|
||||||
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; };
|
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; };
|
||||||
|
@ -559,7 +561,7 @@
|
||||||
D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; };
|
D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; };
|
||||||
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = "<group>"; };
|
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = "<group>"; };
|
||||||
D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuPicker.swift; sourceTree = "<group>"; };
|
D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuPicker.swift; sourceTree = "<group>"; };
|
||||||
D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Pachyderm; sourceTree = "<group>"; };
|
D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Pachyderm; path = Packages/Pachyderm; sourceTree = "<group>"; };
|
||||||
D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
|
D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
|
||||||
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = "<group>"; };
|
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = "<group>"; };
|
||||||
D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAvatarImageView.swift; sourceTree = "<group>"; };
|
D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAvatarImageView.swift; sourceTree = "<group>"; };
|
||||||
|
@ -696,6 +698,8 @@
|
||||||
D6D4DDEB212518A200E1C4BB /* TuskerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TuskerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
D6D4DDEB212518A200E1C4BB /* TuskerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TuskerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerUITests.swift; sourceTree = "<group>"; };
|
D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerUITests.swift; sourceTree = "<group>"; };
|
||||||
D6D4DDF1212518A200E1C4BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
D6D4DDF1212518A200E1C4BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollingSegmentedControl.swift; sourceTree = "<group>"; };
|
||||||
|
D6D706A62948D4D0000827ED /* TimlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimlineState.swift; sourceTree = "<group>"; };
|
||||||
D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = "<group>"; };
|
D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = "<group>"; };
|
||||||
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = "<group>"; };
|
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = "<group>"; };
|
||||||
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; };
|
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; };
|
||||||
|
@ -931,6 +935,7 @@
|
||||||
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */,
|
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */,
|
||||||
D61F759A29384F9C00C0B37F /* FilterMO.swift */,
|
D61F759A29384F9C00C0B37F /* FilterMO.swift */,
|
||||||
D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */,
|
D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */,
|
||||||
|
D6D706A62948D4D0000827ED /* TimlineState.swift */,
|
||||||
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
|
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
|
||||||
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
|
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
|
||||||
);
|
);
|
||||||
|
@ -1364,6 +1369,7 @@
|
||||||
D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */,
|
D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */,
|
||||||
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
|
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
|
||||||
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
|
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
|
||||||
|
D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */,
|
||||||
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
|
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
|
||||||
D620483723D38190008A63EF /* StatusContentTextView.swift */,
|
D620483723D38190008A63EF /* StatusContentTextView.swift */,
|
||||||
04ED00B021481ED800567C53 /* SteppedProgressView.swift */,
|
04ED00B021481ED800567C53 /* SteppedProgressView.swift */,
|
||||||
|
@ -1409,7 +1415,6 @@
|
||||||
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
|
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
|
||||||
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */,
|
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */,
|
||||||
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */,
|
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */,
|
||||||
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
|
|
||||||
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */,
|
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */,
|
||||||
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */,
|
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */,
|
||||||
D61F759129365C6C00C0B37F /* CollectionViewController.swift */,
|
D61F759129365C6C00C0B37F /* CollectionViewController.swift */,
|
||||||
|
@ -1483,6 +1488,7 @@
|
||||||
D6F1F84E2193B9BE00F5FE67 /* Caching */,
|
D6F1F84E2193B9BE00F5FE67 /* Caching */,
|
||||||
D6370B9924421FE00092A7FF /* CoreData */,
|
D6370B9924421FE00092A7FF /* CoreData */,
|
||||||
D667E5F62135C2ED0057A976 /* Extensions */,
|
D667E5F62135C2ED0057A976 /* Extensions */,
|
||||||
|
D6D706A12947D954000827ED /* Gestures */,
|
||||||
D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */,
|
D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */,
|
||||||
D6E57FA525C26FAB00341037 /* Localizable.stringsdict */,
|
D6E57FA525C26FAB00341037 /* Localizable.stringsdict */,
|
||||||
D61959D2241E846D00A37B8E /* Models */,
|
D61959D2241E846D00A37B8E /* Models */,
|
||||||
|
@ -1523,6 +1529,14 @@
|
||||||
path = TuskerUITests;
|
path = TuskerUITests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
D6D706A12947D954000827ED /* Gestures */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
|
||||||
|
);
|
||||||
|
path = Gestures;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */ = {
|
D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -2035,6 +2049,7 @@
|
||||||
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
|
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
|
||||||
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
|
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
|
||||||
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
|
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
|
||||||
|
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */,
|
||||||
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */,
|
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */,
|
||||||
D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */,
|
D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */,
|
||||||
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
|
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
|
||||||
|
@ -2059,6 +2074,7 @@
|
||||||
D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */,
|
D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */,
|
||||||
D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */,
|
D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */,
|
||||||
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */,
|
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */,
|
||||||
|
D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */,
|
||||||
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */,
|
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */,
|
||||||
D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */,
|
D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */,
|
||||||
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
|
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
|
||||||
|
@ -2291,7 +2307,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 51;
|
CURRENT_PROJECT_VERSION = 52;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
@ -2359,7 +2375,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 51;
|
CURRENT_PROJECT_VERSION = 52;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||||
|
@ -2509,7 +2525,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 51;
|
CURRENT_PROJECT_VERSION = 52;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
@ -2538,7 +2554,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 51;
|
CURRENT_PROJECT_VERSION = 52;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
@ -2648,7 +2664,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 51;
|
CURRENT_PROJECT_VERSION = 52;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||||
|
@ -2675,7 +2691,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 51;
|
CURRENT_PROJECT_VERSION = 52;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||||
|
|
|
@ -257,8 +257,8 @@ private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) {
|
||||||
]
|
]
|
||||||
if let nodeInfo {
|
if let nodeInfo {
|
||||||
crumb.data!["nodeInfo"] = [
|
crumb.data!["nodeInfo"] = [
|
||||||
"version": nodeInfo.version,
|
"software": nodeInfo.software.name,
|
||||||
"software": nodeInfo.software,
|
"version": nodeInfo.software.version,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
SentrySDK.addBreadcrumb(crumb: crumb)
|
SentrySDK.addBreadcrumb(crumb: crumb)
|
||||||
|
|
|
@ -355,6 +355,15 @@ class MastodonCachePersistentStore: NSPersistentContainer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getTimelineState(timeline: Timeline) -> TimelineState? {
|
||||||
|
do {
|
||||||
|
let req = TimelineState.fetchRequest(timeline: timeline)
|
||||||
|
return try viewContext.fetch(req).first
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {
|
@objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {
|
||||||
let changes = hasChangedSavedHashtagsOrInstances(notification)
|
let changes = hasChangedSavedHashtagsOrInstances(notification)
|
||||||
if changes.hashtags {
|
if changes.hashtags {
|
||||||
|
|
|
@ -18,6 +18,12 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
|
||||||
return NSFetchRequest<StatusMO>(entityName: "Status")
|
return NSFetchRequest<StatusMO>(entityName: "Status")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@nonobjc public class func fetchRequest(id: String) -> NSFetchRequest<StatusMO> {
|
||||||
|
let req = Self.fetchRequest()
|
||||||
|
req.predicate = NSPredicate(format: "id = %@", id)
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
@NSManaged public var applicationName: String?
|
@NSManaged public var applicationName: String?
|
||||||
@NSManaged private var attachmentsData: Data?
|
@NSManaged private var attachmentsData: Data?
|
||||||
@NSManaged private var bookmarkedInternal: Bool
|
@NSManaged private var bookmarkedInternal: Bool
|
||||||
|
|
|
@ -0,0 +1,89 @@
|
||||||
|
//
|
||||||
|
// TimlineState.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/13/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
@objc(TimelineState)
|
||||||
|
public final class TimelineState: NSManagedObject {
|
||||||
|
|
||||||
|
@nonobjc public class func fetchRequest(timeline: Timeline) -> NSFetchRequest<TimelineState> {
|
||||||
|
let req = NSFetchRequest<TimelineState>(entityName: "TimelineState")
|
||||||
|
req.predicate = NSPredicate(format: "timelineKind = %@", toTimelineKind(timeline))
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged private var timelineKind: String
|
||||||
|
@NSManaged public var centerStatusID: String?
|
||||||
|
@NSManaged private var statuses: NSOrderedSet
|
||||||
|
|
||||||
|
var timeline: Timeline {
|
||||||
|
get { fromTimelineKind(timelineKind) }
|
||||||
|
set { timelineKind = toTimelineKind(newValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusMOs: [StatusMO] {
|
||||||
|
statuses.array as! [StatusMO]
|
||||||
|
}
|
||||||
|
|
||||||
|
convenience init(timeline: Timeline, context: NSManagedObjectContext) {
|
||||||
|
self.init(context: context)
|
||||||
|
self.timeline = timeline
|
||||||
|
}
|
||||||
|
|
||||||
|
func setStatuses(_ statusIDs: [String]) {
|
||||||
|
let context = managedObjectContext!
|
||||||
|
// todo: this feels really inefficient, but I'm not sure if it's better or worse than doing a single "id IN %@" fetch and sorting after
|
||||||
|
let mos = statusIDs.compactMap { try? context.fetch(StatusMO.fetchRequest(id: $0)).first }
|
||||||
|
self.statuses = NSOrderedSet(array: mos)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// blergh, this is the simplest way of getting the Timeline into a format that A) CoreData can handle and B) is usable in the predicate
|
||||||
|
private func toTimelineKind(_ timeline: Timeline) -> String {
|
||||||
|
switch timeline {
|
||||||
|
case .home:
|
||||||
|
return "home"
|
||||||
|
case .public(local: true):
|
||||||
|
return "local"
|
||||||
|
case .public(local: false):
|
||||||
|
return "federated"
|
||||||
|
case .direct:
|
||||||
|
return "direct"
|
||||||
|
case .tag(hashtag: let name):
|
||||||
|
return "hashtag:\(name)"
|
||||||
|
case .list(id: let id):
|
||||||
|
return "list:\(id)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fromTimelineKind(_ kind: String) -> Timeline {
|
||||||
|
if kind == "home" {
|
||||||
|
return .home
|
||||||
|
} else if kind == "local" {
|
||||||
|
return .public(local: true)
|
||||||
|
} else if kind == "federated" {
|
||||||
|
return .public(local: false)
|
||||||
|
} else if kind == "direct" {
|
||||||
|
return .direct
|
||||||
|
} else if kind.starts(with: "hashtag:") {
|
||||||
|
return .tag(hashtag: String(trimmingPrefix("hashtag:", of: kind)))
|
||||||
|
} else if kind.starts(with: "list:") {
|
||||||
|
return .list(id: String(trimmingPrefix("list:", of: kind)))
|
||||||
|
} else {
|
||||||
|
fatalError("invalid timeline kind \(kind)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace with Collection.trimmingPrefix
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
private func trimmingPrefix(_ prefix: String, of str: String) -> Substring {
|
||||||
|
return str[str.index(str.startIndex, offsetBy: prefix.count)...]
|
||||||
|
}
|
|
@ -105,10 +105,16 @@
|
||||||
<attribute name="visibilityString" attributeType="String"/>
|
<attribute name="visibilityString" attributeType="String"/>
|
||||||
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="statuses" inverseEntity="Account"/>
|
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="statuses" inverseEntity="Account"/>
|
||||||
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status"/>
|
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status"/>
|
||||||
|
<relationship name="timelines" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="TimelineState" inverseName="statuses" inverseEntity="TimelineState"/>
|
||||||
<uniquenessConstraints>
|
<uniquenessConstraints>
|
||||||
<uniquenessConstraint>
|
<uniquenessConstraint>
|
||||||
<constraint value="id"/>
|
<constraint value="id"/>
|
||||||
</uniquenessConstraint>
|
</uniquenessConstraint>
|
||||||
</uniquenessConstraints>
|
</uniquenessConstraints>
|
||||||
</entity>
|
</entity>
|
||||||
|
<entity name="TimelineState" representedClassName="TimelineState" syncable="YES">
|
||||||
|
<attribute name="centerStatusID" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="timelineKind" attributeType="String" valueTransformerName="pachydermTimeline" customClassName="Tusker.TimelineContainer"/>
|
||||||
|
<relationship name="statuses" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="Status" inverseName="timelines" inverseEntity="Status"/>
|
||||||
|
</entity>
|
||||||
</model>
|
</model>
|
|
@ -20,18 +20,30 @@ extension AccountMO {
|
||||||
}
|
}
|
||||||
|
|
||||||
var displayNameWithoutCustomEmoji: String {
|
var displayNameWithoutCustomEmoji: String {
|
||||||
if displayName.isEmpty {
|
let stripped = stripCustomEmoji(from: displayName)
|
||||||
|
if stripped.isEmpty {
|
||||||
return username
|
return username
|
||||||
} else {
|
} else {
|
||||||
return stripCustomEmoji(from: displayName)
|
return stripped
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let customEmojiRegex = try! NSRegularExpression(pattern: ":[a-zA-Z0-9_]+:", options: [])
|
}
|
||||||
|
|
||||||
private func stripCustomEmoji(from string: String) -> String {
|
extension Account {
|
||||||
let range = NSRange(location: 0, length: string.utf16.count)
|
var displayNameWithoutCustomEmoji: String {
|
||||||
return AccountMO.customEmojiRegex.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
|
let stripped = stripCustomEmoji(from: displayName)
|
||||||
}
|
if stripped.isEmpty {
|
||||||
|
return username
|
||||||
|
} else {
|
||||||
|
return stripped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let customEmojiRegex = try! NSRegularExpression(pattern: ":[a-zA-Z0-9_]+:", options: [])
|
||||||
|
|
||||||
|
private func stripCustomEmoji(from string: String) -> String {
|
||||||
|
let range = NSRange(location: 0, length: string.utf16.count)
|
||||||
|
return customEmojiRegex.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
|
||||||
}
|
}
|
||||||
|
|
|
@ -106,9 +106,9 @@ struct HTMLConverter {
|
||||||
let index = (try? node.elementSiblingIndex()) ?? 0
|
let index = (try? node.elementSiblingIndex()) ?? 0
|
||||||
// we use the monospace digit font so that the periods of all the list items line up
|
// we use the monospace digit font so that the periods of all the list items line up
|
||||||
// TODO: this probably breaks with dynamic type
|
// TODO: this probably breaks with dynamic type
|
||||||
bullet = NSAttributedString(string: "\(index + 1).\t", attributes: [.font: UIFont.monospacedDigitSystemFont(ofSize: font.pointSize, weight: .regular)])
|
bullet = NSAttributedString(string: "\(index + 1).\t", attributes: [.font: UIFont.monospacedDigitSystemFont(ofSize: font.pointSize, weight: .regular), .foregroundColor: color])
|
||||||
} else if parentTag == "ul" {
|
} else if parentTag == "ul" {
|
||||||
bullet = NSAttributedString(string: "\u{2022}\t", attributes: [.font: font])
|
bullet = NSAttributedString(string: "\u{2022}\t", attributes: [.font: font, .foregroundColor: color])
|
||||||
} else {
|
} else {
|
||||||
bullet = NSAttributedString()
|
bullet = NSAttributedString()
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,6 +43,8 @@ class Preferences: Codable, ObservableObject {
|
||||||
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
|
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
|
||||||
self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon)
|
self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon)
|
||||||
self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false
|
self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false
|
||||||
|
self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions
|
||||||
|
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
|
||||||
|
|
||||||
self.defaultPostVisibility = try container.decode(Status.Visibility.self, forKey: .defaultPostVisibility)
|
self.defaultPostVisibility = try container.decode(Status.Visibility.self, forKey: .defaultPostVisibility)
|
||||||
self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
|
self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
|
||||||
|
@ -52,7 +54,11 @@ class Preferences: Codable, ObservableObject {
|
||||||
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
|
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
|
||||||
self.useTwitterKeyboard = try container.decodeIfPresent(Bool.self, forKey: .useTwitterKeyboard) ?? false
|
self.useTwitterKeyboard = try container.decodeIfPresent(Bool.self, forKey: .useTwitterKeyboard) ?? false
|
||||||
|
|
||||||
self.blurAllMedia = try container.decode(Bool.self, forKey: .blurAllMedia)
|
if let blurAllMedia = try? container.decodeIfPresent(Bool.self, forKey: .blurAllMedia) {
|
||||||
|
self.attachmentBlurMode = blurAllMedia ? .always : .useStatusSetting
|
||||||
|
} else {
|
||||||
|
self.attachmentBlurMode = try container.decode(AttachmentBlurMode.self, forKey: .attachmentBlurMode)
|
||||||
|
}
|
||||||
self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true
|
self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true
|
||||||
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
|
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
|
||||||
|
|
||||||
|
@ -86,6 +92,8 @@ class Preferences: Codable, ObservableObject {
|
||||||
try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon)
|
try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon)
|
||||||
try container.encode(alwaysShowStatusVisibilityIcon, forKey: .alwaysShowStatusVisibilityIcon)
|
try container.encode(alwaysShowStatusVisibilityIcon, forKey: .alwaysShowStatusVisibilityIcon)
|
||||||
try container.encode(hideActionsInTimeline, forKey: .hideActionsInTimeline)
|
try container.encode(hideActionsInTimeline, forKey: .hideActionsInTimeline)
|
||||||
|
try container.encode(leadingStatusSwipeActions, forKey: .leadingStatusSwipeActions)
|
||||||
|
try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions)
|
||||||
|
|
||||||
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
|
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
|
||||||
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
|
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
|
||||||
|
@ -95,7 +103,7 @@ class Preferences: Codable, ObservableObject {
|
||||||
try container.encode(mentionReblogger, forKey: .mentionReblogger)
|
try container.encode(mentionReblogger, forKey: .mentionReblogger)
|
||||||
try container.encode(useTwitterKeyboard, forKey: .useTwitterKeyboard)
|
try container.encode(useTwitterKeyboard, forKey: .useTwitterKeyboard)
|
||||||
|
|
||||||
try container.encode(blurAllMedia, forKey: .blurAllMedia)
|
try container.encode(attachmentBlurMode, forKey: .attachmentBlurMode)
|
||||||
try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning)
|
try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning)
|
||||||
try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
|
try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
|
||||||
|
|
||||||
|
@ -140,10 +148,12 @@ class Preferences: Codable, ObservableObject {
|
||||||
@Published var useTwitterKeyboard = false
|
@Published var useTwitterKeyboard = false
|
||||||
|
|
||||||
// MARK: Media
|
// MARK: Media
|
||||||
@Published var blurAllMedia = false {
|
@Published var attachmentBlurMode = AttachmentBlurMode.useStatusSetting {
|
||||||
didSet {
|
didSet {
|
||||||
if blurAllMedia {
|
if attachmentBlurMode == .always {
|
||||||
blurMediaBehindContentWarning = true
|
blurMediaBehindContentWarning = true
|
||||||
|
} else if attachmentBlurMode == .never {
|
||||||
|
blurMediaBehindContentWarning = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -182,6 +192,8 @@ class Preferences: Codable, ObservableObject {
|
||||||
case showIsStatusReplyIcon
|
case showIsStatusReplyIcon
|
||||||
case alwaysShowStatusVisibilityIcon
|
case alwaysShowStatusVisibilityIcon
|
||||||
case hideActionsInTimeline
|
case hideActionsInTimeline
|
||||||
|
case leadingStatusSwipeActions
|
||||||
|
case trailingStatusSwipeActions
|
||||||
|
|
||||||
case defaultPostVisibility
|
case defaultPostVisibility
|
||||||
case defaultReplyVisibility
|
case defaultReplyVisibility
|
||||||
|
@ -191,7 +203,8 @@ class Preferences: Codable, ObservableObject {
|
||||||
case mentionReblogger
|
case mentionReblogger
|
||||||
case useTwitterKeyboard
|
case useTwitterKeyboard
|
||||||
|
|
||||||
case blurAllMedia
|
case blurAllMedia // only used for migration
|
||||||
|
case attachmentBlurMode
|
||||||
case blurMediaBehindContentWarning
|
case blurMediaBehindContentWarning
|
||||||
case automaticallyPlayGifs
|
case automaticallyPlayGifs
|
||||||
|
|
||||||
|
@ -254,4 +267,23 @@ extension Preferences {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Preferences {
|
||||||
|
enum AttachmentBlurMode: Codable, Hashable, CaseIterable {
|
||||||
|
case useStatusSetting
|
||||||
|
case always
|
||||||
|
case never
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .useStatusSetting:
|
||||||
|
return "Default"
|
||||||
|
case .always:
|
||||||
|
return "Always"
|
||||||
|
case .never:
|
||||||
|
return "Never"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension UIUserInterfaceStyle: Codable {}
|
extension UIUserInterfaceStyle: Codable {}
|
||||||
|
|
|
@ -131,7 +131,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
minDate.addTimeInterval(-7 * 24 * 60 * 60)
|
minDate.addTimeInterval(-7 * 24 * 60 * 60)
|
||||||
|
|
||||||
let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest()
|
let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest()
|
||||||
statusReq.predicate = NSPredicate(format: "(lastFetchedAt = nil) OR (lastFetchedAt < %@)", minDate as NSDate)
|
statusReq.predicate = NSPredicate(format: "((lastFetchedAt = nil) OR (lastFetchedAt < %@)) AND (timelines.@count = 0)", minDate as NSDate)
|
||||||
let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq)
|
let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq)
|
||||||
deleteStatusReq.resultType = .resultTypeCount
|
deleteStatusReq.resultType = .resultTypeCount
|
||||||
if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult {
|
if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult {
|
||||||
|
|
|
@ -136,6 +136,11 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
|
||||||
vc.player?.play()
|
vc.player?.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func accessibilityPerformEscape() -> Bool {
|
||||||
|
dismiss(animated: true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Page View Controller Data Source
|
// MARK: - Page View Controller Data Source
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,7 @@ protocol ComposeUIStateDelegate: AnyObject {
|
||||||
func presentAssetPickerSheet()
|
func presentAssetPickerSheet()
|
||||||
func presentComposeDrawing()
|
func presentComposeDrawing()
|
||||||
func selectDraft(_ draft: Draft)
|
func selectDraft(_ draft: Draft)
|
||||||
|
func paste(itemProviders: [NSItemProvider])
|
||||||
}
|
}
|
||||||
|
|
||||||
class ComposeUIState: ObservableObject {
|
class ComposeUIState: ObservableObject {
|
||||||
|
|
|
@ -72,8 +72,24 @@ struct ComposeView: View {
|
||||||
return attachmentIds.contains { uiState.attachmentsMissingDescriptions.contains($0) }
|
return attachmentIds.contains { uiState.attachmentsMissingDescriptions.contains($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var validAttachmentCombination: Bool {
|
||||||
|
if !mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
||||||
|
return true
|
||||||
|
} else if draft.attachments.contains(where: { $0.data.type == .video }) && draft.attachments.count > 1 {
|
||||||
|
return false
|
||||||
|
} else if draft.attachments.count > 4 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
private var postButtonEnabled: Bool {
|
private var postButtonEnabled: Bool {
|
||||||
draft.hasContent && charactersRemaining >= 0 && !isPosting && !requiresAttachmentDescriptions && (draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty })
|
draft.hasContent
|
||||||
|
&& charactersRemaining >= 0
|
||||||
|
&& !isPosting
|
||||||
|
&& !requiresAttachmentDescriptions
|
||||||
|
&& validAttachmentCombination
|
||||||
|
&& (draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty })
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
|
@ -83,7 +83,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
||||||
@Environment(\.isEnabled) var isEnabled: Bool
|
@Environment(\.isEnabled) var isEnabled: Bool
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UITextView {
|
func makeUIView(context: Context) -> UITextView {
|
||||||
let textView = WrappedTextView()
|
let textView = WrappedTextView(uiState: uiState)
|
||||||
textView.delegate = context.coordinator
|
textView.delegate = context.coordinator
|
||||||
textView.isEditable = true
|
textView.isEditable = true
|
||||||
textView.backgroundColor = .clear
|
textView.backgroundColor = .clear
|
||||||
|
@ -128,6 +128,16 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
||||||
|
|
||||||
class WrappedTextView: UITextView {
|
class WrappedTextView: UITextView {
|
||||||
private let formattingActions = [#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))]
|
private let formattingActions = [#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))]
|
||||||
|
unowned var uiState: ComposeUIState
|
||||||
|
|
||||||
|
init(uiState: ComposeUIState) {
|
||||||
|
self.uiState = uiState
|
||||||
|
super.init(frame: .zero, textContainer: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||||
if formattingActions.contains(action) {
|
if formattingActions.contains(action) {
|
||||||
|
@ -154,6 +164,14 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func paste(_ sender: Any?) {
|
||||||
|
if UIPasteboard.general.contains(pasteboardTypes: CompositionAttachment.readableTypeIdentifiersForItemProvider) {
|
||||||
|
uiState.delegate?.paste(itemProviders: UIPasteboard.general.itemProviders)
|
||||||
|
} else {
|
||||||
|
super.paste(sender)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Coordinator: NSObject, UITextViewDelegate, ComposeInput, ComposeTextViewCaretScrolling {
|
class Coordinator: NSObject, UITextViewDelegate, ComposeInput, ComposeTextViewCaretScrolling {
|
||||||
|
|
|
@ -354,6 +354,7 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
case let .status(id: id, state: state) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) {
|
case let .status(id: id, state: state) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) {
|
||||||
let conv = ConversationTableViewController(for: id, state: state, mastodonController: mastodonController)
|
let conv = ConversationTableViewController(for: id, state: state, mastodonController: mastodonController)
|
||||||
conv.statusIDToScrollToOnLoad = childThreads.first!.status.id
|
conv.statusIDToScrollToOnLoad = childThreads.first!.status.id
|
||||||
|
conv.showStatusesAutomatically = showStatusesAutomatically
|
||||||
show(conv)
|
show(conv)
|
||||||
} else {
|
} else {
|
||||||
super.tableView(tableView, didSelectRowAt: indexPath)
|
super.tableView(tableView, didSelectRowAt: indexPath)
|
||||||
|
|
|
@ -84,7 +84,7 @@ class AddSavedHashtagViewController: UIViewController {
|
||||||
navigationItem.searchController = searchController
|
navigationItem.searchController = searchController
|
||||||
navigationItem.hidesSearchBarWhenScrolling = false
|
navigationItem.hidesSearchBarWhenScrolling = false
|
||||||
|
|
||||||
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonPressed))
|
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneButtonPressed))
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
@ -107,15 +107,12 @@ class AddSavedHashtagViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func selectHashtag(_ hashtag: Hashtag) {
|
private func selectHashtag(_ hashtag: Hashtag) {
|
||||||
let context = mastodonController.persistentContainer.viewContext
|
show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil)
|
||||||
_ = SavedHashtag(hashtag: hashtag, context: context)
|
|
||||||
try! context.save()
|
|
||||||
presentingViewController!.dismiss(animated: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Interaction
|
// MARK: - Interaction
|
||||||
|
|
||||||
@objc func cancelButtonPressed() {
|
@objc func doneButtonPressed() {
|
||||||
dismiss(animated: true)
|
dismiss(animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -128,11 +125,6 @@ extension AddSavedHashtagViewController {
|
||||||
enum Item: Hashable {
|
enum Item: Hashable {
|
||||||
case tag(Hashtag)
|
case tag(Hashtag)
|
||||||
}
|
}
|
||||||
// class DataSource: UITableViewDiffableDataSource<Section, Item> {
|
|
||||||
// override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AddSavedHashtagViewController: UICollectionViewDelegate {
|
extension AddSavedHashtagViewController: UICollectionViewDelegate {
|
||||||
|
|
|
@ -24,7 +24,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
||||||
|
|
||||||
var searchControllerStatusOnAppearance: Bool? = nil
|
var searchControllerStatusOnAppearance: Bool? = nil
|
||||||
|
|
||||||
private var listsCancellable: AnyCancellable?
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(mastodonController: MastodonController) {
|
init(mastodonController: MastodonController) {
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
|
@ -70,12 +70,26 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
||||||
navigationItem.searchController = searchController
|
navigationItem.searchController = searchController
|
||||||
navigationItem.hidesSearchBarWhenScrolling = false
|
navigationItem.hidesSearchBarWhenScrolling = false
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(savedHashtagsChanged), name: .savedHashtagsChanged, object: nil)
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(savedInstancesChanged), name: .savedInstancesChanged, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(savedInstancesChanged), name: .savedInstancesChanged, object: nil)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||||
|
|
||||||
listsCancellable = mastodonController.$lists
|
mastodonController.$lists
|
||||||
.sink { [unowned self] in self.reloadLists($0) }
|
.sink { [unowned self] in self.reloadLists($0) }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
mastodonController.$followedHashtags
|
||||||
|
.merge(with:
|
||||||
|
NotificationCenter.default.publisher(for: .savedHashtagsChanged)
|
||||||
|
.map { [unowned self] _ in self.mastodonController.followedHashtags }
|
||||||
|
)
|
||||||
|
.sink { [unowned self] in self.updateHashtagsSection(followed: $0) }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
let a = PassthroughSubject<Int, Never>()
|
||||||
|
let b = PassthroughSubject<Int, Never>()
|
||||||
|
|
||||||
|
a.merge(with: b)
|
||||||
|
.sink(receiveValue: { print($0) })
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
@ -149,9 +163,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
||||||
addDiscoverSection(to: &snapshot)
|
addDiscoverSection(to: &snapshot)
|
||||||
}
|
}
|
||||||
snapshot.appendItems([.addList], toSection: .lists)
|
snapshot.appendItems([.addList], toSection: .lists)
|
||||||
let hashtags = fetchSavedHashtags().map {
|
let hashtags = fetchHashtagItems(followed: mastodonController.followedHashtags)
|
||||||
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
|
|
||||||
}
|
|
||||||
snapshot.appendItems(hashtags, toSection: .savedHashtags)
|
snapshot.appendItems(hashtags, toSection: .savedHashtags)
|
||||||
snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags)
|
snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags)
|
||||||
let instances = fetchSavedInstances().map {
|
let instances = fetchSavedInstances().map {
|
||||||
|
@ -193,14 +205,16 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func fetchSavedHashtags() -> [SavedHashtag] {
|
private func fetchHashtagItems(followed: [FollowedHashtag]) -> [Item] {
|
||||||
let req = SavedHashtag.fetchRequest()
|
let saved = (try? mastodonController.persistentContainer.viewContext.fetch(SavedHashtag.fetchRequest())) ?? []
|
||||||
req.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true, selector: #selector(NSString.localizedCompare(_:)))]
|
var items = saved.map {
|
||||||
do {
|
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
|
||||||
return try mastodonController.persistentContainer.viewContext.fetch(req)
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
|
for followed in followed where !saved.contains(where: { $0.name == followed.name }) {
|
||||||
|
items.append(.savedHashtag(Hashtag(name: followed.name, url: followed.url)))
|
||||||
|
}
|
||||||
|
items.sort(using: SemiCaseSensitiveComparator.keyPath(\.label))
|
||||||
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
@ -214,12 +228,10 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func savedHashtagsChanged() {
|
private func updateHashtagsSection(followed: [FollowedHashtag]) {
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedHashtags))
|
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedHashtags))
|
||||||
let hashtags = fetchSavedHashtags().map {
|
let hashtags = fetchHashtagItems(followed: followed)
|
||||||
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
|
|
||||||
}
|
|
||||||
snapshot.appendItems(hashtags, toSection: .savedHashtags)
|
snapshot.appendItems(hashtags, toSection: .savedHashtags)
|
||||||
snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags)
|
snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags)
|
||||||
dataSource.apply(snapshot)
|
dataSource.apply(snapshot)
|
||||||
|
@ -386,7 +398,7 @@ extension ExploreViewController {
|
||||||
case .lists:
|
case .lists:
|
||||||
return NSLocalizedString("Lists", comment: "explore lists section title")
|
return NSLocalizedString("Lists", comment: "explore lists section title")
|
||||||
case .savedHashtags:
|
case .savedHashtags:
|
||||||
return NSLocalizedString("Saved Hashtags", comment: "explore saved hashtags section title")
|
return NSLocalizedString("Hashtags", comment: "explore saved hashtags section title")
|
||||||
case .savedInstances:
|
case .savedInstances:
|
||||||
return NSLocalizedString("Instance Timelines", comment: "explore instance timelines section title")
|
return NSLocalizedString("Instance Timelines", comment: "explore instance timelines section title")
|
||||||
}
|
}
|
||||||
|
@ -425,7 +437,7 @@ extension ExploreViewController {
|
||||||
case let .savedHashtag(hashtag):
|
case let .savedHashtag(hashtag):
|
||||||
return hashtag.name
|
return hashtag.name
|
||||||
case .addSavedHashtag:
|
case .addSavedHashtag:
|
||||||
return NSLocalizedString("Save Hashtag...", comment: "save hashtag nav item title")
|
return NSLocalizedString("Add Hashtag...", comment: "save hashtag nav item title")
|
||||||
case let .savedInstance(url):
|
case let .savedInstance(url):
|
||||||
return url.host!
|
return url.host!
|
||||||
case .findInstance:
|
case .findInstance:
|
||||||
|
|
|
@ -122,5 +122,24 @@ class FeaturedProfileCollectionViewCell: UICollectionViewCell {
|
||||||
displayNameLabel.updateForAccountDisplayName(account: account)
|
displayNameLabel.updateForAccountDisplayName(account: account)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Accessibility
|
||||||
|
|
||||||
|
override var isAccessibilityElement: Bool {
|
||||||
|
get { true }
|
||||||
|
set {}
|
||||||
|
}
|
||||||
|
|
||||||
|
override var accessibilityAttributedLabel: NSAttributedString? {
|
||||||
|
get {
|
||||||
|
guard let account else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let s = NSMutableAttributedString(string: "\(account.displayNameWithoutCustomEmoji), ")
|
||||||
|
s.append(noteTextView.attributedText)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
set {}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,8 +34,9 @@ class ProfileDirectoryViewController: UIViewController {
|
||||||
|
|
||||||
title = NSLocalizedString("Profile Directory", comment: "profile directory title")
|
title = NSLocalizedString("Profile Directory", comment: "profile directory title")
|
||||||
|
|
||||||
// todo: it would be nice if there were a better "filter" icon
|
let filterItem = UIBarButtonItem(image: UIImage(systemName: "line.3.horizontal.decrease.circle"), menu: nil)
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "scope"), menu: nil)
|
filterItem.accessibilityLabel = "Filter"
|
||||||
|
navigationItem.rightBarButtonItem = filterItem
|
||||||
updateFilterMenu()
|
updateFilterMenu()
|
||||||
|
|
||||||
let layout = UICollectionViewCompositionalLayout(sectionProvider: { (sectionIndex, layoutEnvironment) in
|
let layout = UICollectionViewCompositionalLayout(sectionProvider: { (sectionIndex, layoutEnvironment) in
|
||||||
|
|
|
@ -85,14 +85,14 @@ class TrendingStatusesViewController: UIViewController {
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
if !loaded {
|
||||||
snapshot.appendSections([.statuses])
|
loaded = true
|
||||||
snapshot.appendItems([.loadingIndicator])
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
dataSource.apply(snapshot, animatingDifferences: false)
|
snapshot.appendSections([.statuses])
|
||||||
|
snapshot.appendItems([.loadingIndicator])
|
||||||
Task {
|
dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
if !loaded {
|
|
||||||
loaded = true
|
Task {
|
||||||
await loadTrendingStatuses()
|
await loadTrendingStatuses()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,10 +17,10 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
||||||
|
|
||||||
@IBOutlet weak var scrollView: UIScrollView!
|
@IBOutlet weak var scrollView: UIScrollView!
|
||||||
@IBOutlet weak var topControlsView: UIView!
|
@IBOutlet weak var topControlsView: UIView!
|
||||||
@IBOutlet weak var bottomControlsView: UIView!
|
@IBOutlet weak var descriptionTextView: UITextView!
|
||||||
@IBOutlet weak var descriptionLabel: UILabel!
|
|
||||||
|
|
||||||
private var shareContainer: UIView!
|
private var shareContainer: UIView!
|
||||||
|
private var closeContainer: UIView!
|
||||||
private var shareImage: UIImageView!
|
private var shareImage: UIImageView!
|
||||||
private var shareButtonTopConstraint: NSLayoutConstraint!
|
private var shareButtonTopConstraint: NSLayoutConstraint!
|
||||||
private var shareButtonLeadingConstraint: NSLayoutConstraint!
|
private var shareButtonLeadingConstraint: NSLayoutConstraint!
|
||||||
|
@ -46,6 +46,8 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
||||||
}
|
}
|
||||||
var shrinkGestureEnabled = true
|
var shrinkGestureEnabled = true
|
||||||
|
|
||||||
|
private var isInitialAppearance = true
|
||||||
|
private var skipUpdatingControlsWhileZooming = false
|
||||||
private var prevZoomScale: CGFloat?
|
private var prevZoomScale: CGFloat?
|
||||||
private var isGrayscale = false
|
private var isGrayscale = false
|
||||||
private var contentViewSizeObservation: NSKeyValueObservation?
|
private var contentViewSizeObservation: NSKeyValueObservation?
|
||||||
|
@ -98,9 +100,14 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
||||||
|
|
||||||
if let imageDescription = imageDescription,
|
if let imageDescription = imageDescription,
|
||||||
!imageDescription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
!imageDescription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
descriptionLabel.text = imageDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
descriptionTextView.text = imageDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
descriptionTextView.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
|
||||||
|
// i'm not sure why .automatic doesn't work for this
|
||||||
|
descriptionTextView.contentInsetAdjustmentBehavior = .always
|
||||||
|
let height = min(150, descriptionTextView.contentSize.height)
|
||||||
|
descriptionTextView.topAnchor.constraint(equalTo: descriptionTextView.safeAreaLayoutGuide.bottomAnchor, constant: -(height + 16)).isActive = true
|
||||||
} else {
|
} else {
|
||||||
bottomControlsView.isHidden = true
|
descriptionTextView.isHidden = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if shrinkGestureEnabled {
|
if shrinkGestureEnabled {
|
||||||
|
@ -116,6 +123,12 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
||||||
view.addGestureRecognizer(doubleTap)
|
view.addGestureRecognizer(doubleTap)
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||||
|
|
||||||
|
accessibilityElements = [
|
||||||
|
topControlsView!,
|
||||||
|
contentView,
|
||||||
|
descriptionTextView!,
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupContentView() {
|
private func setupContentView() {
|
||||||
|
@ -135,6 +148,9 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
||||||
|
|
||||||
private func setupControls() {
|
private func setupControls() {
|
||||||
shareContainer = UIView()
|
shareContainer = UIView()
|
||||||
|
shareContainer.isAccessibilityElement = true
|
||||||
|
shareContainer.accessibilityTraits = .button
|
||||||
|
shareContainer.accessibilityLabel = "Share"
|
||||||
shareContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(sharePressed)))
|
shareContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(sharePressed)))
|
||||||
shareContainer.translatesAutoresizingMaskIntoConstraints = false
|
shareContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||||
topControlsView.addSubview(shareContainer)
|
topControlsView.addSubview(shareContainer)
|
||||||
|
@ -161,7 +177,10 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
||||||
shareImage.heightAnchor.constraint(equalToConstant: 24),
|
shareImage.heightAnchor.constraint(equalToConstant: 24),
|
||||||
])
|
])
|
||||||
|
|
||||||
let closeContainer = UIView()
|
closeContainer = UIView()
|
||||||
|
closeContainer.isAccessibilityElement = true
|
||||||
|
closeContainer.accessibilityTraits = .button
|
||||||
|
closeContainer.accessibilityLabel = "Close"
|
||||||
closeContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(closeButtonPressed)))
|
closeContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(closeButtonPressed)))
|
||||||
closeContainer.translatesAutoresizingMaskIntoConstraints = false
|
closeContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||||
topControlsView.addSubview(closeContainer)
|
topControlsView.addSubview(closeContainer)
|
||||||
|
@ -198,9 +217,11 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
||||||
let heightScale = maxHeight / contentView.intrinsicContentSize.height
|
let heightScale = maxHeight / contentView.intrinsicContentSize.height
|
||||||
let widthScale = view.bounds.width / contentView.intrinsicContentSize.width
|
let widthScale = view.bounds.width / contentView.intrinsicContentSize.width
|
||||||
let minScale = min(widthScale, heightScale)
|
let minScale = min(widthScale, heightScale)
|
||||||
|
skipUpdatingControlsWhileZooming = true
|
||||||
scrollView.minimumZoomScale = minScale
|
scrollView.minimumZoomScale = minScale
|
||||||
scrollView.zoomScale = minScale
|
scrollView.zoomScale = minScale
|
||||||
scrollView.maximumZoomScale = minScale >= 1 ? minScale + 2 : 2
|
scrollView.maximumZoomScale = minScale >= 1 ? minScale + 2 : 2
|
||||||
|
skipUpdatingControlsWhileZooming = false
|
||||||
|
|
||||||
centerImage()
|
centerImage()
|
||||||
|
|
||||||
|
@ -230,6 +251,26 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func viewSafeAreaInsetsDidChange() {
|
||||||
|
super.viewSafeAreaInsetsDidChange()
|
||||||
|
// the controls view transforms take the safe area insets into account, so they need to be updated
|
||||||
|
updateControlsView()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
|
||||||
|
// on the first appearance, the text view flashes its own scroll indicators automatically
|
||||||
|
// so we only need to do it on subsequent appearances
|
||||||
|
if isInitialAppearance {
|
||||||
|
isInitialAppearance = false
|
||||||
|
} else {
|
||||||
|
if animated && controlsVisible && !descriptionTextView.isHidden {
|
||||||
|
descriptionTextView.flashScrollIndicators()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func preferencesChanged() {
|
@objc private func preferencesChanged() {
|
||||||
if isGrayscale != Preferences.shared.grayscaleImages {
|
if isGrayscale != Preferences.shared.grayscaleImages {
|
||||||
isGrayscale = Preferences.shared.grayscaleImages
|
isGrayscale = Preferences.shared.grayscaleImages
|
||||||
|
@ -244,17 +285,20 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
||||||
self.contentView.setControlsVisible(controlsVisible)
|
self.contentView.setControlsVisible(controlsVisible)
|
||||||
self.updateControlsView()
|
self.updateControlsView()
|
||||||
}
|
}
|
||||||
|
if controlsVisible && !descriptionTextView.isHidden {
|
||||||
|
descriptionTextView.flashScrollIndicators()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
updateControlsView()
|
updateControlsView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateControlsView() {
|
func updateControlsView() {
|
||||||
let topOffset = self.controlsVisible ? 0 : -self.topControlsView.bounds.height
|
let topOffset = self.controlsVisible ? 0 : -(self.topControlsView.bounds.height + self.view.safeAreaInsets.top)
|
||||||
self.topControlsView.transform = CGAffineTransform(translationX: 0, y: topOffset)
|
self.topControlsView.transform = CGAffineTransform(translationX: 0, y: topOffset)
|
||||||
if self.imageDescription != nil {
|
if self.imageDescription != nil {
|
||||||
let bottomOffset = self.controlsVisible ? 0 : self.bottomControlsView.bounds.height + self.view.safeAreaInsets.bottom
|
let bottomOffset = self.controlsVisible ? 0 : self.descriptionTextView.bounds.height + self.view.safeAreaInsets.bottom
|
||||||
self.bottomControlsView.transform = CGAffineTransform(translationX: 0, y: bottomOffset)
|
self.descriptionTextView.transform = CGAffineTransform(translationX: 0, y: bottomOffset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -264,10 +308,12 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
||||||
|
|
||||||
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||||
let prevZoomScale = self.prevZoomScale ?? scrollView.minimumZoomScale
|
let prevZoomScale = self.prevZoomScale ?? scrollView.minimumZoomScale
|
||||||
if scrollView.zoomScale <= scrollView.minimumZoomScale {
|
if !skipUpdatingControlsWhileZooming {
|
||||||
setControlsVisible(true, animated: true)
|
if scrollView.zoomScale <= scrollView.minimumZoomScale {
|
||||||
} else if scrollView.zoomScale > prevZoomScale {
|
setControlsVisible(true, animated: true)
|
||||||
setControlsVisible(false, animated: true)
|
} else if scrollView.zoomScale > prevZoomScale {
|
||||||
|
setControlsVisible(false, animated: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.prevZoomScale = scrollView.zoomScale
|
self.prevZoomScale = scrollView.zoomScale
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,8 +10,7 @@
|
||||||
<objects>
|
<objects>
|
||||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="LargeImageViewController" customModule="Tusker" customModuleProvider="target">
|
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="LargeImageViewController" customModule="Tusker" customModuleProvider="target">
|
||||||
<connections>
|
<connections>
|
||||||
<outlet property="bottomControlsView" destination="rPa-Zu-T6g" id="Rgz-AQ-9nt"/>
|
<outlet property="descriptionTextView" destination="JZk-BO-2Vh" id="cby-Hc-ezg"/>
|
||||||
<outlet property="descriptionLabel" destination="eo5-fc-RV8" id="vrW-RJ-y5k"/>
|
|
||||||
<outlet property="scrollView" destination="Skj-xq-AgQ" id="TFb-zF-m1b"/>
|
<outlet property="scrollView" destination="Skj-xq-AgQ" id="TFb-zF-m1b"/>
|
||||||
<outlet property="topControlsView" destination="kHo-B9-R7a" id="8sJ-xQ-7ix"/>
|
<outlet property="topControlsView" destination="kHo-B9-R7a" id="8sJ-xQ-7ix"/>
|
||||||
<outlet property="view" destination="BJw-5C-9nT" id="1C2-VA-mNf"/>
|
<outlet property="view" destination="BJw-5C-9nT" id="1C2-VA-mNf"/>
|
||||||
|
@ -29,41 +28,34 @@
|
||||||
<view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="kHo-B9-R7a">
|
<view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="kHo-B9-R7a">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="375" height="36"/>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="36"/>
|
||||||
</view>
|
</view>
|
||||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="rPa-Zu-T6g">
|
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" editable="NO" textAlignment="natural" adjustsFontForContentSizeCategory="YES" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="JZk-BO-2Vh">
|
||||||
<rect key="frame" x="0.0" y="622.5" width="375" height="44.5"/>
|
<rect key="frame" x="0.0" y="517" width="375" height="150"/>
|
||||||
<subviews>
|
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.5" colorSpace="custom" customColorSpace="displayP3"/>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="eo5-fc-RV8">
|
|
||||||
<rect key="frame" x="16" y="8" width="343" height="20.5"/>
|
|
||||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
</subviews>
|
|
||||||
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.5" colorSpace="custom" customColorSpace="sRGB"/>
|
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="eo5-fc-RV8" firstAttribute="top" secondItem="rPa-Zu-T6g" secondAttribute="top" constant="8" id="6n3-E0-2G6"/>
|
<constraint firstAttribute="height" constant="150" placeholder="YES" id="YfV-kQ-0RT"/>
|
||||||
<constraint firstAttribute="trailing" secondItem="eo5-fc-RV8" secondAttribute="trailing" constant="16" id="6uL-vY-tqk"/>
|
|
||||||
<constraint firstItem="eo5-fc-RV8" firstAttribute="leading" secondItem="rPa-Zu-T6g" secondAttribute="leading" constant="16" id="KIF-vw-K7n"/>
|
|
||||||
<constraint firstAttribute="bottom" secondItem="eo5-fc-RV8" secondAttribute="bottom" constant="16" id="v43-mS-tyR"/>
|
|
||||||
</constraints>
|
</constraints>
|
||||||
</view>
|
<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" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||||
|
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
||||||
|
</textView>
|
||||||
</subviews>
|
</subviews>
|
||||||
<viewLayoutGuide key="safeArea" id="w1g-VC-Ll9"/>
|
<viewLayoutGuide key="safeArea" id="w1g-VC-Ll9"/>
|
||||||
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<gestureRecognizers/>
|
<gestureRecognizers/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="Skj-xq-AgQ" firstAttribute="centerY" secondItem="BJw-5C-9nT" secondAttribute="centerY" id="0Xb-ib-2hg"/>
|
<constraint firstItem="Skj-xq-AgQ" firstAttribute="centerY" secondItem="BJw-5C-9nT" secondAttribute="centerY" id="0Xb-ib-2hg"/>
|
||||||
<constraint firstItem="w1g-VC-Ll9" firstAttribute="trailing" secondItem="rPa-Zu-T6g" secondAttribute="trailing" id="2GG-7P-Qv1"/>
|
<constraint firstAttribute="bottom" secondItem="JZk-BO-2Vh" secondAttribute="bottom" id="7Z2-gW-sPj"/>
|
||||||
<constraint firstItem="w1g-VC-Ll9" firstAttribute="bottom" secondItem="rPa-Zu-T6g" secondAttribute="bottom" id="3qf-5e-vl0"/>
|
|
||||||
<constraint firstItem="kHo-B9-R7a" firstAttribute="leading" secondItem="w1g-VC-Ll9" secondAttribute="leading" id="IvH-gU-Kie"/>
|
<constraint firstItem="kHo-B9-R7a" firstAttribute="leading" secondItem="w1g-VC-Ll9" secondAttribute="leading" id="IvH-gU-Kie"/>
|
||||||
|
<constraint firstAttribute="trailing" secondItem="JZk-BO-2Vh" secondAttribute="trailing" id="JgV-jy-qjS"/>
|
||||||
<constraint firstItem="Skj-xq-AgQ" firstAttribute="centerX" secondItem="BJw-5C-9nT" secondAttribute="centerX" id="KMe-Zc-NZq"/>
|
<constraint firstItem="Skj-xq-AgQ" firstAttribute="centerX" secondItem="BJw-5C-9nT" secondAttribute="centerX" id="KMe-Zc-NZq"/>
|
||||||
<constraint firstItem="Skj-xq-AgQ" firstAttribute="width" secondItem="BJw-5C-9nT" secondAttribute="width" id="Onj-l9-fBu"/>
|
<constraint firstItem="Skj-xq-AgQ" firstAttribute="width" secondItem="BJw-5C-9nT" secondAttribute="width" id="Onj-l9-fBu"/>
|
||||||
<constraint firstItem="w1g-VC-Ll9" firstAttribute="trailing" secondItem="kHo-B9-R7a" secondAttribute="trailing" id="Uh0-ub-R9V"/>
|
<constraint firstItem="w1g-VC-Ll9" firstAttribute="trailing" secondItem="kHo-B9-R7a" secondAttribute="trailing" id="Uh0-ub-R9V"/>
|
||||||
<constraint firstItem="rPa-Zu-T6g" firstAttribute="leading" secondItem="w1g-VC-Ll9" secondAttribute="leading" id="asz-Xj-FUC"/>
|
|
||||||
<constraint firstItem="Skj-xq-AgQ" firstAttribute="height" secondItem="BJw-5C-9nT" secondAttribute="height" id="jvz-QW-n9c"/>
|
<constraint firstItem="Skj-xq-AgQ" firstAttribute="height" secondItem="BJw-5C-9nT" secondAttribute="height" id="jvz-QW-n9c"/>
|
||||||
|
<constraint firstItem="JZk-BO-2Vh" firstAttribute="leading" secondItem="BJw-5C-9nT" secondAttribute="leading" id="kkj-O9-1rE"/>
|
||||||
<constraint firstItem="kHo-B9-R7a" firstAttribute="top" secondItem="BJw-5C-9nT" secondAttribute="top" id="n1O-C3-yQR"/>
|
<constraint firstItem="kHo-B9-R7a" firstAttribute="top" secondItem="BJw-5C-9nT" secondAttribute="top" id="n1O-C3-yQR"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
<point key="canvasLocation" x="-164" y="476"/>
|
<point key="canvasLocation" x="-164" y="475.41229385307349"/>
|
||||||
</view>
|
</view>
|
||||||
</objects>
|
</objects>
|
||||||
</document>
|
</document>
|
||||||
|
|
|
@ -80,7 +80,7 @@ class MainSidebarMyProfileCollectionViewCell: UICollectionViewListCell {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
config.imageProperties.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * avatarImageSize
|
config.imageProperties.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * avatarImageSize
|
||||||
self.contentConfiguration = contentConfiguration
|
self.contentConfiguration = config
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,7 +30,7 @@ class MainSidebarViewController: UIViewController {
|
||||||
private var collectionView: UICollectionView!
|
private var collectionView: UICollectionView!
|
||||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
|
|
||||||
private var listsCancellable: AnyCancellable?
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
var allItems: [Item] {
|
var allItems: [Item] {
|
||||||
[
|
[
|
||||||
|
@ -101,12 +101,19 @@ class MainSidebarViewController: UIViewController {
|
||||||
|
|
||||||
select(item: .tab(.timelines), animated: false)
|
select(item: .tab(.timelines), animated: false)
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedHashtags), name: .savedHashtagsChanged, object: nil)
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||||
|
|
||||||
listsCancellable = mastodonController.$lists
|
mastodonController.$lists
|
||||||
.sink { [unowned self] in self.reloadLists($0) }
|
.sink { [unowned self] in self.reloadLists($0) }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
mastodonController.$followedHashtags
|
||||||
|
.merge(with:
|
||||||
|
NotificationCenter.default.publisher(for: .savedHashtagsChanged)
|
||||||
|
.map { [unowned self] _ in self.mastodonController.followedHashtags }
|
||||||
|
)
|
||||||
|
.sink { [unowned self] in self.updateHashtagsSection(followed: $0) }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
onViewDidLoad?()
|
onViewDidLoad?()
|
||||||
}
|
}
|
||||||
|
@ -176,7 +183,7 @@ class MainSidebarViewController: UIViewController {
|
||||||
|
|
||||||
applyDiscoverSectionSnapshot()
|
applyDiscoverSectionSnapshot()
|
||||||
reloadLists(mastodonController.lists)
|
reloadLists(mastodonController.lists)
|
||||||
reloadSavedHashtags()
|
updateHashtagsSection(followed: mastodonController.followedHashtags)
|
||||||
reloadSavedInstances()
|
reloadSavedInstances()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -224,14 +231,16 @@ class MainSidebarViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func fetchSavedHashtags() -> [SavedHashtag] {
|
private func fetchHashtagItems(followed: [FollowedHashtag]) -> [Item] {
|
||||||
let req = SavedHashtag.fetchRequest()
|
let saved = (try? mastodonController.persistentContainer.viewContext.fetch(SavedHashtag.fetchRequest())) ?? []
|
||||||
req.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true, selector: #selector(NSString.localizedCompare(_:)))]
|
var items = saved.map {
|
||||||
do {
|
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
|
||||||
return try mastodonController.persistentContainer.viewContext.fetch(req)
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
|
for followed in followed where !saved.contains(where: { $0.name == followed.name }) {
|
||||||
|
items.append(.savedHashtag(Hashtag(name: followed.name, url: followed.url)))
|
||||||
|
}
|
||||||
|
items.sort(using: SemiCaseSensitiveComparator.keyPath(\.title))
|
||||||
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
@ -245,10 +254,8 @@ class MainSidebarViewController: UIViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func reloadSavedHashtags() {
|
private func updateHashtagsSection(followed: [FollowedHashtag]) {
|
||||||
let hashtags = fetchSavedHashtags().map {
|
let hashtags = fetchHashtagItems(followed: followed)
|
||||||
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
|
|
||||||
}
|
|
||||||
if let selectedItem,
|
if let selectedItem,
|
||||||
case .savedHashtag(_) = selectedItem,
|
case .savedHashtag(_) = selectedItem,
|
||||||
!hashtags.contains(selectedItem) {
|
!hashtags.contains(selectedItem) {
|
||||||
|
@ -403,13 +410,13 @@ extension MainSidebarViewController {
|
||||||
case .addList:
|
case .addList:
|
||||||
return "New List..."
|
return "New List..."
|
||||||
case .savedHashtagsHeader:
|
case .savedHashtagsHeader:
|
||||||
return "Saved Hashtags"
|
return "Hashtags"
|
||||||
case let .savedHashtag(hashtag):
|
case let .savedHashtag(hashtag):
|
||||||
return hashtag.name
|
return hashtag.name
|
||||||
case .addSavedHashtag:
|
case .addSavedHashtag:
|
||||||
return "Save Hashtag..."
|
return "Add Hashtag..."
|
||||||
case .savedInstancesHeader:
|
case .savedInstancesHeader:
|
||||||
return "Saved Instances"
|
return "Instance Timelines"
|
||||||
case let .savedInstance(url):
|
case let .savedInstance(url):
|
||||||
return url.host!
|
return url.host!
|
||||||
case .addSavedInstance:
|
case .addSavedInstance:
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
class NotificationsPageViewController: SegmentedPageViewController {
|
class NotificationsPageViewController: SegmentedPageViewController<NotificationsPageViewController.Page> {
|
||||||
|
|
||||||
private let notificationsTitle = NSLocalizedString("Notifications", comment: "notifications tab title")
|
private let notificationsTitle = NSLocalizedString("Notifications", comment: "notifications tab title")
|
||||||
private let mentionsTitle = NSLocalizedString("Mentions", comment: "mentions tab title")
|
private let mentionsTitle = NSLocalizedString("Mentions", comment: "mentions tab title")
|
||||||
|
@ -30,12 +30,9 @@ class NotificationsPageViewController: SegmentedPageViewController {
|
||||||
mentions.title = mentionsTitle
|
mentions.title = mentionsTitle
|
||||||
mentions.userActivity = UserActivityManager.checkNotificationsActivity(mode: .mentionsOnly)
|
mentions.userActivity = UserActivityManager.checkNotificationsActivity(mode: .mentionsOnly)
|
||||||
|
|
||||||
super.init(titles: [
|
super.init(pages: [
|
||||||
notificationsTitle,
|
(.all, notificationsTitle, notifications),
|
||||||
mentionsTitle
|
(.mentions, mentionsTitle, mentions),
|
||||||
], pageControllers: [
|
|
||||||
notifications,
|
|
||||||
mentions
|
|
||||||
])
|
])
|
||||||
|
|
||||||
title = notificationsTitle
|
title = notificationsTitle
|
||||||
|
@ -53,15 +50,20 @@ class NotificationsPageViewController: SegmentedPageViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectMode(_ mode: NotificationsMode) {
|
func selectMode(_ mode: NotificationsMode) {
|
||||||
let index: Int
|
let page: Page
|
||||||
switch mode {
|
switch mode {
|
||||||
case .allNotifications:
|
case .allNotifications:
|
||||||
index = 0
|
page = .all
|
||||||
case .mentionsOnly:
|
case .mentionsOnly:
|
||||||
index = 1
|
page = .mentions
|
||||||
}
|
}
|
||||||
segmentedControl.selectedSegmentIndex = index
|
segmentedControl.setSelectedOption(page, animated: false)
|
||||||
selectPage(at: index, animated: false)
|
selectPage(page, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Page {
|
||||||
|
case all
|
||||||
|
case mentions
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,14 +21,18 @@ struct MediaPrefsView: View {
|
||||||
|
|
||||||
var viewingSection: some View {
|
var viewingSection: some View {
|
||||||
Section(header: Text("Viewing")) {
|
Section(header: Text("Viewing")) {
|
||||||
Toggle(isOn: $preferences.blurAllMedia) {
|
Picker(selection: $preferences.attachmentBlurMode) {
|
||||||
Text("Blur All Media")
|
ForEach(Preferences.AttachmentBlurMode.allCases, id: \.self) { mode in
|
||||||
|
Text(mode.displayName).tag(mode)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Blur Media")
|
||||||
}
|
}
|
||||||
|
|
||||||
Toggle(isOn: $preferences.blurMediaBehindContentWarning) {
|
Toggle(isOn: $preferences.blurMediaBehindContentWarning) {
|
||||||
Text("Blur Media Behind Content Warning")
|
Text("Blur Media Behind Content Warning")
|
||||||
}
|
}
|
||||||
.disabled(preferences.blurAllMedia)
|
.disabled(preferences.attachmentBlurMode != .useStatusSetting)
|
||||||
|
|
||||||
Toggle(isOn: $preferences.automaticallyPlayGifs) {
|
Toggle(isOn: $preferences.automaticallyPlayGifs) {
|
||||||
Text("Automatically Play GIFs")
|
Text("Automatically Play GIFs")
|
||||||
|
|
|
@ -142,7 +142,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
let view = ProfileHeaderView.create()
|
let view = ProfileHeaderView.create()
|
||||||
view.delegate = self.profileHeaderDelegate
|
view.delegate = self.profileHeaderDelegate
|
||||||
view.updateUI(for: id)
|
view.updateUI(for: id)
|
||||||
view.pagesSegmentedControl.selectedSegmentIndex = self.owner?.currentIndex ?? 0
|
view.pagesSegmentedControl.setSelectedOption(self.owner!.currentPage, animated: false)
|
||||||
cell.addHeader(view)
|
cell.addHeader(view)
|
||||||
case .useExistingView(let view):
|
case .useExistingView(let view):
|
||||||
cell.addHeader(view)
|
cell.addHeader(view)
|
||||||
|
|
|
@ -29,7 +29,11 @@ class ProfileViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private(set) var currentIndex: Int!
|
private(set) var currentIndex: Int!
|
||||||
|
private let pages = [Page.posts, .postsAndReplies, .media]
|
||||||
private var pageControllers: [ProfileStatusesViewController]!
|
private var pageControllers: [ProfileStatusesViewController]!
|
||||||
|
var currentPage: Page {
|
||||||
|
pages[currentIndex]
|
||||||
|
}
|
||||||
var currentViewController: ProfileStatusesViewController {
|
var currentViewController: ProfileStatusesViewController {
|
||||||
pageControllers[currentIndex]
|
pageControllers[currentIndex]
|
||||||
}
|
}
|
||||||
|
@ -283,6 +287,14 @@ class ProfileViewController: UIViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ProfileViewController {
|
||||||
|
enum Page: Hashable {
|
||||||
|
case posts
|
||||||
|
case postsAndReplies
|
||||||
|
case media
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension ProfileViewController {
|
extension ProfileViewController {
|
||||||
enum State {
|
enum State {
|
||||||
case idle
|
case idle
|
||||||
|
@ -298,24 +310,25 @@ extension ProfileViewController: ToastableViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ProfileViewController: ProfileHeaderViewDelegate {
|
extension ProfileViewController: ProfileHeaderViewDelegate {
|
||||||
func profileHeader(_ headerView: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int) {
|
func profileHeader(_ headerView: ProfileHeaderView, selectedPageChangedTo newPage: Page) {
|
||||||
guard case .idle = state else {
|
guard case .idle = state else {
|
||||||
|
headerView.pagesSegmentedControl.setSelectedOption(currentPage, animated: false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
selectPage(at: newIndex, animated: true)
|
selectPage(at: pages.firstIndex(of: newPage)!, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ProfileViewController: TabbedPageViewController {
|
extension ProfileViewController: TabbedPageViewController {
|
||||||
func selectNextPage() {
|
func selectNextPage() {
|
||||||
guard currentIndex < pageControllers.count - 1 else { return }
|
guard currentIndex < pageControllers.count - 1 else { return }
|
||||||
currentViewController.headerCell?.view?.pagesSegmentedControl.selectedSegmentIndex = currentIndex + 1
|
currentViewController.headerCell?.view?.pagesSegmentedControl.setSelectedOption(pages[currentIndex + 1], animated: true)
|
||||||
selectPage(at: currentIndex + 1, animated: true)
|
selectPage(at: currentIndex + 1, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectPrevPage() {
|
func selectPrevPage() {
|
||||||
guard currentIndex > 0 else { return }
|
guard currentIndex > 0 else { return }
|
||||||
currentViewController.headerCell?.view?.pagesSegmentedControl.selectedSegmentIndex = currentIndex - 1
|
currentViewController.headerCell?.view?.pagesSegmentedControl.setSelectedOption(pages[currentIndex - 1], animated: true)
|
||||||
selectPage(at: currentIndex - 1, animated: true)
|
selectPage(at: currentIndex - 1, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
weak var mastodonController: MastodonController!
|
weak var mastodonController: MastodonController!
|
||||||
let filterer: Filterer
|
let filterer: Filterer
|
||||||
|
|
||||||
|
var persistsState = false
|
||||||
|
|
||||||
private(set) var controller: TimelineLikeController<TimelineItem>!
|
private(set) var controller: TimelineLikeController<TimelineItem>!
|
||||||
let confirmLoadMore = PassthroughSubject<Void, Never>()
|
let confirmLoadMore = PassthroughSubject<Void, Never>()
|
||||||
// stored separately because i don't want to query the snapshot every time the user scrolls
|
// stored separately because i don't want to query the snapshot every time the user scrolls
|
||||||
|
@ -196,7 +198,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
}
|
}
|
||||||
|
|
||||||
if case .notLoadedInitial = controller.state {
|
if case .notLoadedInitial = controller.state {
|
||||||
if doRestore() {
|
if restoreState() {
|
||||||
Task {
|
Task {
|
||||||
await checkPresent(jumpImmediately: false)
|
await checkPresent(jumpImmediately: false)
|
||||||
}
|
}
|
||||||
|
@ -227,22 +229,23 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
super.viewDidDisappear(animated)
|
super.viewDidDisappear(animated)
|
||||||
|
|
||||||
disappearedAt = Date()
|
disappearedAt = Date()
|
||||||
|
saveState()
|
||||||
}
|
}
|
||||||
|
|
||||||
func stateRestorationActivity() -> NSUserActivity? {
|
private func saveState() {
|
||||||
guard isViewLoaded else {
|
guard isViewLoaded,
|
||||||
return nil
|
persistsState else {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
let visible = collectionView.indexPathsForVisibleItems.sorted()
|
let visible = collectionView.indexPathsForVisibleItems.sorted()
|
||||||
let snapshot = dataSource.snapshot()
|
let snapshot = dataSource.snapshot()
|
||||||
let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size)
|
let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size)
|
||||||
let midPoint = CGPoint(x: visibleRect.midX, y: visibleRect.midY)
|
let midPoint = CGPoint(x: visibleRect.midX, y: visibleRect.midY)
|
||||||
guard let currentAccountID = mastodonController.accountInfo?.id,
|
guard !visible.isEmpty,
|
||||||
!visible.isEmpty,
|
|
||||||
let statusesSection = snapshot.sectionIdentifiers.firstIndex(of: .statuses),
|
let statusesSection = snapshot.sectionIdentifiers.firstIndex(of: .statuses),
|
||||||
let rawCenterVisible = collectionView.indexPathForItem(at: midPoint),
|
let rawCenterVisible = collectionView.indexPathForItem(at: midPoint),
|
||||||
let centerVisible = visible.first(where: { $0.section == statusesSection && $0 >= rawCenterVisible }) else {
|
let centerVisible = visible.first(where: { $0.section == statusesSection && $0 >= rawCenterVisible }) else {
|
||||||
return nil
|
return
|
||||||
}
|
}
|
||||||
let allItems = snapshot.itemIdentifiers(inSection: .statuses)
|
let allItems = snapshot.itemIdentifiers(inSection: .statuses)
|
||||||
|
|
||||||
|
@ -282,35 +285,31 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
} else {
|
} else {
|
||||||
fatalError()
|
fatalError()
|
||||||
}
|
}
|
||||||
stateRestorationLogger.debug("TimelineViewController: creating state restoration activity with topID \(centerVisibleID)")
|
stateRestorationLogger.debug("TimelineViewController: saving state to persistent store with with centerID \(centerVisibleID)")
|
||||||
|
|
||||||
|
let state = mastodonController.persistentContainer.getTimelineState(timeline: timeline) ?? TimelineState(timeline: timeline, context: mastodonController.persistentContainer.viewContext)
|
||||||
|
state.setStatuses(ids)
|
||||||
|
state.centerStatusID = centerVisibleID
|
||||||
|
mastodonController.persistentContainer.save(context: mastodonController.persistentContainer.viewContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stateRestorationActivity() -> NSUserActivity? {
|
||||||
|
guard isViewLoaded,
|
||||||
|
let currentAccountID = mastodonController.accountInfo?.id else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
let activity = UserActivityManager.showTimelineActivity(timeline: timeline, accountID: currentAccountID)!
|
let activity = UserActivityManager.showTimelineActivity(timeline: timeline, accountID: currentAccountID)!
|
||||||
activity.addUserInfoEntries(from: [
|
|
||||||
"statusIDs": ids,
|
|
||||||
"centerID": centerVisibleID,
|
|
||||||
])
|
|
||||||
activity.isEligibleForPrediction = false
|
activity.isEligibleForPrediction = false
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreActivity(_ activity: NSUserActivity) {
|
private func restoreState() -> Bool {
|
||||||
self.activityToRestore = activity
|
guard persistsState,
|
||||||
}
|
Preferences.shared.timelineStateRestoration,
|
||||||
|
let state = mastodonController.persistentContainer.getTimelineState(timeline: timeline) else {
|
||||||
private func doRestore() -> Bool {
|
|
||||||
guard let activity = activityToRestore,
|
|
||||||
Preferences.shared.timelineStateRestoration else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
guard let statusIDs = activity.userInfo?["statusIDs"] as? [String] else {
|
|
||||||
stateRestorationLogger.fault("TimelineViewController: activity missing statusIDs")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
activityToRestore = nil
|
|
||||||
let existingStatuses = statusIDs.filter { mastodonController.persistentContainer.status(for: $0) != nil }
|
|
||||||
guard !existingStatuses.isEmpty else {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
let statusIDs = state.statusMOs.map(\.id)
|
||||||
loadViewIfNeeded()
|
loadViewIfNeeded()
|
||||||
controller.restoreInitial {
|
controller.restoreInitial {
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
|
@ -318,7 +317,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
let items = statusIDs.map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
|
let items = statusIDs.map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
|
||||||
snapshot.appendItems(items, toSection: .statuses)
|
snapshot.appendItems(items, toSection: .statuses)
|
||||||
dataSource.apply(snapshot, animatingDifferences: false) {
|
dataSource.apply(snapshot, animatingDifferences: false) {
|
||||||
if let centerID = activity.userInfo?["centerID"] as? String ?? activity.userInfo?["topID"] as? String,
|
if let centerID = state.centerStatusID,
|
||||||
let index = statusIDs.firstIndex(of: centerID),
|
let index = statusIDs.firstIndex(of: centerID),
|
||||||
let indexPath = self.dataSource.indexPath(for: items[index]) {
|
let indexPath = self.dataSource.indexPath(for: items[index]) {
|
||||||
// it sometimes takes multiple attempts to convert on the right scroll position
|
// it sometimes takes multiple attempts to convert on the right scroll position
|
||||||
|
@ -345,7 +344,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
|
|
||||||
private func removeTimelineDescriptionCell() {
|
private func removeTimelineDescriptionCell() {
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
snapshot.deleteSections([.header])
|
snapshot.deleteItems([.publicTimelineDescription])
|
||||||
dataSource.apply(snapshot, animatingDifferences: true)
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
isShowingTimelineDescription = false
|
isShowingTimelineDescription = false
|
||||||
}
|
}
|
||||||
|
@ -400,6 +399,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
disappearedAt = Date()
|
disappearedAt = Date()
|
||||||
|
saveState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func refresh() {
|
@objc func refresh() {
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
class TimelinesPageViewController: SegmentedPageViewController {
|
class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageViewController.Page> {
|
||||||
|
|
||||||
private let homeTitle = NSLocalizedString("Home", comment: "home timeline tab title")
|
private let homeTitle = NSLocalizedString("Home", comment: "home timeline tab title")
|
||||||
private let federatedTitle = NSLocalizedString("Federated", comment: "federated timeline tab title")
|
private let federatedTitle = NSLocalizedString("Federated", comment: "federated timeline tab title")
|
||||||
|
@ -22,21 +22,20 @@ class TimelinesPageViewController: SegmentedPageViewController {
|
||||||
|
|
||||||
let home = TimelineViewController(for: .home, mastodonController: mastodonController)
|
let home = TimelineViewController(for: .home, mastodonController: mastodonController)
|
||||||
home.title = homeTitle
|
home.title = homeTitle
|
||||||
|
home.persistsState = true
|
||||||
|
|
||||||
let federated = TimelineViewController(for: .public(local: false), mastodonController: mastodonController)
|
let federated = TimelineViewController(for: .public(local: false), mastodonController: mastodonController)
|
||||||
federated.title = federatedTitle
|
federated.title = federatedTitle
|
||||||
|
federated.persistsState = true
|
||||||
|
|
||||||
let local = TimelineViewController(for: .public(local: true), mastodonController: mastodonController)
|
let local = TimelineViewController(for: .public(local: true), mastodonController: mastodonController)
|
||||||
local.title = localTitle
|
local.title = localTitle
|
||||||
|
local.persistsState = true
|
||||||
|
|
||||||
super.init(titles: [
|
super.init(pages: [
|
||||||
homeTitle,
|
(.home, "Home", home),
|
||||||
federatedTitle,
|
(.local, "Local", local),
|
||||||
localTitle
|
(.federated, "Federated", federated),
|
||||||
], pageControllers: [
|
|
||||||
home,
|
|
||||||
federated,
|
|
||||||
local
|
|
||||||
])
|
])
|
||||||
|
|
||||||
title = homeTitle
|
title = homeTitle
|
||||||
|
@ -75,24 +74,28 @@ class TimelinesPageViewController: SegmentedPageViewController {
|
||||||
guard let timeline = UserActivityManager.getTimeline(from: activity) else {
|
guard let timeline = UserActivityManager.getTimeline(from: activity) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let index: Int
|
let page: Page
|
||||||
switch timeline {
|
switch timeline {
|
||||||
case .home:
|
case .home:
|
||||||
index = 0
|
page = .home
|
||||||
case .public(local: false):
|
case .public(local: false):
|
||||||
index = 1
|
page = .federated
|
||||||
case .public(local: true):
|
case .public(local: true):
|
||||||
index = 2
|
page = .local
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
selectPage(at: index, animated: false)
|
selectPage(page, animated: false)
|
||||||
let timelineVC = pageControllers[index] as! TimelineViewController
|
|
||||||
timelineVC.restoreActivity(activity)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func filtersPressed() {
|
@objc private func filtersPressed() {
|
||||||
present(UIHostingController(rootView: FiltersView(mastodonController: mastodonController)), animated: true)
|
present(UIHostingController(rootView: FiltersView(mastodonController: mastodonController)), animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum Page: Hashable {
|
||||||
|
case home
|
||||||
|
case local
|
||||||
|
case federated
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -394,6 +394,8 @@ class CustomAlertActionButton: UIControl {
|
||||||
self.isContextMenuInteractionEnabled = true
|
self.isContextMenuInteractionEnabled = true
|
||||||
self.showsMenuAsPrimaryAction = action.handler == nil
|
self.showsMenuAsPrimaryAction = action.handler == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addGestureRecognizer(UIHoverGestureRecognizer(target: self, action: #selector(hoverRecognized)))
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
|
@ -429,6 +431,17 @@ class CustomAlertActionButton: UIControl {
|
||||||
super.contextMenuInteraction(interaction, willEndFor: configuration, animator: animator)
|
super.contextMenuInteraction(interaction, willEndFor: configuration, animator: animator)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func hoverRecognized(_ recognizer: UIHoverGestureRecognizer) {
|
||||||
|
switch recognizer.state {
|
||||||
|
case .began, .changed:
|
||||||
|
backgroundColor = .secondarySystemFill
|
||||||
|
case .ended:
|
||||||
|
backgroundColor = nil
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
super.touchesBegan(touches, with: event)
|
super.touchesBegan(touches, with: event)
|
||||||
|
|
||||||
|
|
|
@ -8,33 +8,45 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDelegate {
|
class SegmentedPageViewController<Page: Hashable>: UIPageViewController, UIPageViewControllerDelegate, TabbedPageViewController {
|
||||||
|
|
||||||
let titles: [String]
|
let pages: [Page]
|
||||||
let pageControllers: [UIViewController]
|
let pageControllers: [UIViewController]
|
||||||
|
|
||||||
private var initialIndex = 0
|
private var initialPage: Page
|
||||||
private(set) var currentIndex = 0
|
private var currentPage: Page
|
||||||
|
var currentIndex: Int {
|
||||||
|
pages.firstIndex(of: currentPage)!
|
||||||
|
}
|
||||||
|
|
||||||
var segmentedControl: UISegmentedControl!
|
let segmentedControl = ScrollingSegmentedControl<Page>()
|
||||||
|
|
||||||
init(titles: [String], pageControllers: [UIViewController]) {
|
init(pages: [(Page, String, UIViewController)]) {
|
||||||
precondition(!pageControllers.isEmpty)
|
precondition(!pages.isEmpty)
|
||||||
|
|
||||||
self.titles = titles
|
self.pages = pages.map(\.0)
|
||||||
self.pageControllers = pageControllers
|
self.pageControllers = pages.map(\.2)
|
||||||
|
|
||||||
|
initialPage = self.pages.first!
|
||||||
|
currentPage = self.pages.first!
|
||||||
|
|
||||||
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
|
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
|
||||||
|
|
||||||
self.delegate = self
|
|
||||||
|
|
||||||
// this needs to happen in init because EnhancedNavigationViewController expects to be able to look at the titleView
|
// this needs to happen in init because EnhancedNavigationViewController expects to be able to look at the titleView
|
||||||
// before the view has necessarily loaded
|
// before the view has necessarily loaded
|
||||||
segmentedControl = UISegmentedControl(items: titles)
|
segmentedControl.options = pages.map {
|
||||||
segmentedControl.addTarget(self, action: #selector(segmentedControlChanged), for: .valueChanged)
|
.init(value: $0.0, name: $0.1)
|
||||||
|
}
|
||||||
|
segmentedControl.didSelectOption = { [unowned self] option in
|
||||||
|
if let option {
|
||||||
|
self.selectPage(option, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO: the custom segmented control isn't treated as a group and I have no idea how to change that
|
||||||
// the segemented control itself is only focusable when VoiceOver is in Group navigation mode,
|
// the segemented control itself is only focusable when VoiceOver is in Group navigation mode,
|
||||||
// so make it clear that to switch tabs the user needs to enter the group
|
// so make it clear that to switch tabs the user needs to enter the group
|
||||||
segmentedControl.accessibilityHint = "Enter group to select timeline"
|
segmentedControl.accessibilityHint = "Enter group to select timeline"
|
||||||
|
segmentedControl.setSelectedOption(segmentedControl.options.first!.value, animated: false)
|
||||||
navigationItem.titleView = segmentedControl
|
navigationItem.titleView = segmentedControl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,7 +59,7 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
|
||||||
|
|
||||||
view.backgroundColor = .systemBackground
|
view.backgroundColor = .systemBackground
|
||||||
|
|
||||||
selectPage(at: initialIndex, animated: false)
|
selectPage(initialPage, animated: false)
|
||||||
|
|
||||||
addKeyCommand(MenuController.prevSubTabCommand)
|
addKeyCommand(MenuController.prevSubTabCommand)
|
||||||
addKeyCommand(MenuController.nextSubTabCommand)
|
addKeyCommand(MenuController.nextSubTabCommand)
|
||||||
|
@ -60,28 +72,36 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectPage(at index: Int, animated: Bool) {
|
func selectPage(_ page: Page, animated: Bool) {
|
||||||
|
guard pages.contains(page) else {
|
||||||
|
fatalError("invalid page \(page) that is not in SegmentedPageViewController.pages")
|
||||||
|
}
|
||||||
guard isViewLoaded else {
|
guard isViewLoaded else {
|
||||||
initialIndex = index
|
initialPage = page
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let direction: UIPageViewController.NavigationDirection = index - currentIndex > 0 ? .forward : .reverse
|
let prevIndex = currentIndex
|
||||||
setViewControllers([pageControllers[index]], direction: direction, animated: animated)
|
currentPage = page
|
||||||
navigationItem.title = pageControllers[index].title
|
let index = pages.firstIndex(of: page)!
|
||||||
currentIndex = index
|
let newController = pageControllers[index]
|
||||||
segmentedControl.selectedSegmentIndex = index
|
|
||||||
|
let direction: UIPageViewController.NavigationDirection = index - prevIndex > 0 ? .forward : .reverse
|
||||||
|
setViewControllers([newController], direction: direction, animated: animated)
|
||||||
|
navigationItem.title = newController.title
|
||||||
|
|
||||||
|
segmentedControl.setSelectedOption(page, animated: animated)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func segmentedControlChanged() {
|
// MARK: TabbedPageViewController
|
||||||
selectPage(at: segmentedControl.selectedSegmentIndex, animated: true)
|
|
||||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
func selectNextPage() {
|
||||||
|
guard currentIndex < pageControllers.count - 1 else { return }
|
||||||
|
selectPage(pages[currentIndex + 1], animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Page View Controller Delegate
|
func selectPrevPage() {
|
||||||
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
guard currentIndex > 0 else { return }
|
||||||
currentIndex = pageControllers.firstIndex(of: viewControllers!.first!)!
|
selectPage(pages[currentIndex - 1], animated: true)
|
||||||
segmentedControl.selectedSegmentIndex = currentIndex
|
|
||||||
navigationItem.title = viewControllers!.first!.title
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -94,18 +114,6 @@ extension SegmentedPageViewController: TabBarScrollableViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SegmentedPageViewController: TabbedPageViewController {
|
|
||||||
func selectNextPage() {
|
|
||||||
guard currentIndex < pageControllers.count - 1 else { return }
|
|
||||||
selectPage(at: currentIndex + 1, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectPrevPage() {
|
|
||||||
guard currentIndex > 0 else { return }
|
|
||||||
selectPage(at: currentIndex - 1, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension SegmentedPageViewController: BackgroundableViewController {
|
extension SegmentedPageViewController: BackgroundableViewController {
|
||||||
func sceneDidEnterBackground() {
|
func sceneDidEnterBackground() {
|
||||||
if let current = pageControllers[currentIndex] as? BackgroundableViewController {
|
if let current = pageControllers[currentIndex] as? BackgroundableViewController {
|
||||||
|
|
|
@ -245,7 +245,7 @@ extension SplitNavigationController: StatusBarTappableViewController {
|
||||||
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
||||||
let vcs = viewControllers
|
let vcs = viewControllers
|
||||||
if !canShowSecondaryNav || vcs.count < 2 {
|
if !canShowSecondaryNav || vcs.count < 2 {
|
||||||
return (vcs.first! as? StatusBarTappableViewController)?.handleStatusBarTapped(xPosition: xPosition) ?? .continue
|
return (vcs.last! as? StatusBarTappableViewController)?.handleStatusBarTapped(xPosition: xPosition) ?? .continue
|
||||||
} else {
|
} else {
|
||||||
let positionInRoot = rootNav.view.convert(CGPoint(x: xPosition, y: 0), from: view)
|
let positionInRoot = rootNav.view.convert(CGPoint(x: xPosition, y: 0), from: view)
|
||||||
let positionInSecondary = secondaryNav.view.convert(CGPoint(x: xPosition, y: 0), from: view)
|
let positionInSecondary = secondaryNav.view.convert(CGPoint(x: xPosition, y: 0), from: view)
|
||||||
|
|
|
@ -217,20 +217,19 @@ class UserActivityManager {
|
||||||
switch timeline {
|
switch timeline {
|
||||||
case .home, .public(true), .public(false):
|
case .home, .public(true), .public(false):
|
||||||
navigationController.popToRootViewController(animated: false)
|
navigationController.popToRootViewController(animated: false)
|
||||||
let rootController = navigationController.viewControllers.first! as! SegmentedPageViewController
|
let rootController = navigationController.viewControllers.first! as! TimelinesPageViewController
|
||||||
let index: Int
|
let page: TimelinesPageViewController.Page
|
||||||
switch timeline {
|
switch timeline {
|
||||||
case .home:
|
case .home:
|
||||||
index = 0
|
page = .home
|
||||||
case .public(false):
|
case .public(local: false):
|
||||||
index = 1
|
page = .federated
|
||||||
case .public(true):
|
case .public(local: true):
|
||||||
index = 2
|
page = .local
|
||||||
default:
|
default:
|
||||||
fatalError()
|
fatalError()
|
||||||
}
|
}
|
||||||
rootController.segmentedControl.selectedSegmentIndex = index
|
rootController.selectPage(page, animated: false)
|
||||||
rootController.selectPage(at: index, animated: false)
|
|
||||||
default:
|
default:
|
||||||
let timeline = TimelineViewController(for: timeline, mastodonController: mastodonController)
|
let timeline = TimelineViewController(for: timeline, mastodonController: mastodonController)
|
||||||
navigationController.pushViewController(timeline, animated: false)
|
navigationController.pushViewController(timeline, animated: false)
|
||||||
|
|
|
@ -94,7 +94,7 @@ class AccountCollectionViewCell: UICollectionViewListCell {
|
||||||
let account = mastodonController.persistentContainer.account(for: accountID) else {
|
let account = mastodonController.persistentContainer.account(for: accountID) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var str = AttributedString(account.displayOrUserName)
|
var str = AttributedString(account.displayNameWithoutCustomEmoji)
|
||||||
str += ", @"
|
str += ", @"
|
||||||
str += AttributedString(account.acct)
|
str += AttributedString(account.acct)
|
||||||
return NSAttributedString(str)
|
return NSAttributedString(str)
|
||||||
|
|
|
@ -173,15 +173,17 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||||
|
|
||||||
// MARK: - Navigation
|
// MARK: - Navigation
|
||||||
|
|
||||||
func getViewController(forLink url: URL, inRange range: NSRange) -> UIViewController {
|
func getViewController(forLink url: URL, inRange range: NSRange) -> UIViewController? {
|
||||||
let text = (self.text as NSString).substring(with: range)
|
let text = (self.text as NSString).substring(with: range)
|
||||||
|
|
||||||
if let mention = getMention(for: url, text: text) {
|
if let mention = getMention(for: url, text: text) {
|
||||||
return ProfileViewController(accountID: mention.id, mastodonController: mastodonController!)
|
return ProfileViewController(accountID: mention.id, mastodonController: mastodonController!)
|
||||||
} else if let tag = getHashtag(for: url, text: text) {
|
} else if let tag = getHashtag(for: url, text: text) {
|
||||||
return HashtagTimelineViewController(for: tag, mastodonController: mastodonController!)
|
return HashtagTimelineViewController(for: tag, mastodonController: mastodonController!)
|
||||||
} else {
|
} else if url.scheme == "https" || url.scheme == "http" {
|
||||||
return SFSafariViewController(url: url)
|
return SFSafariViewController(url: url)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ import Pachyderm
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
protocol ProfileHeaderViewDelegate: TuskerNavigationDelegate, MenuActionProvider {
|
protocol ProfileHeaderViewDelegate: TuskerNavigationDelegate, MenuActionProvider {
|
||||||
func profileHeader(_ headerView: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int)
|
func profileHeader(_ headerView: ProfileHeaderView, selectedPageChangedTo newPage: ProfileViewController.Page)
|
||||||
}
|
}
|
||||||
|
|
||||||
class ProfileHeaderView: UIView {
|
class ProfileHeaderView: UIView {
|
||||||
|
@ -35,10 +35,11 @@ class ProfileHeaderView: UIView {
|
||||||
@IBOutlet weak var displayNameLabel: EmojiLabel!
|
@IBOutlet weak var displayNameLabel: EmojiLabel!
|
||||||
@IBOutlet weak var usernameLabel: UILabel!
|
@IBOutlet weak var usernameLabel: UILabel!
|
||||||
@IBOutlet weak var lockImageView: UIImageView!
|
@IBOutlet weak var lockImageView: UIImageView!
|
||||||
|
@IBOutlet weak var vStack: UIStackView!
|
||||||
@IBOutlet weak var relationshipLabel: UILabel!
|
@IBOutlet weak var relationshipLabel: UILabel!
|
||||||
@IBOutlet weak var noteTextView: StatusContentTextView!
|
@IBOutlet weak var noteTextView: StatusContentTextView!
|
||||||
@IBOutlet weak var fieldsView: ProfileFieldsView!
|
@IBOutlet weak var fieldsView: ProfileFieldsView!
|
||||||
@IBOutlet weak var pagesSegmentedControl: UISegmentedControl!
|
private(set) var pagesSegmentedControl: ScrollingSegmentedControl<ProfileViewController.Page>!
|
||||||
|
|
||||||
var accountID: String!
|
var accountID: String!
|
||||||
|
|
||||||
|
@ -83,6 +84,22 @@ class ProfileHeaderView: UIView {
|
||||||
noteTextView.defaultFont = .preferredFont(forTextStyle: .body)
|
noteTextView.defaultFont = .preferredFont(forTextStyle: .body)
|
||||||
noteTextView.adjustsFontForContentSizeCategory = true
|
noteTextView.adjustsFontForContentSizeCategory = true
|
||||||
|
|
||||||
|
pagesSegmentedControl = ScrollingSegmentedControl(frame: .zero)
|
||||||
|
pagesSegmentedControl.options = [
|
||||||
|
.init(value: .posts, name: "Posts"),
|
||||||
|
.init(value: .postsAndReplies, name: "Posts and Replies"),
|
||||||
|
.init(value: .media, name: "Media"),
|
||||||
|
]
|
||||||
|
pagesSegmentedControl.setSelectedOption(.posts, animated: false)
|
||||||
|
pagesSegmentedControl.didSelectOption = { [unowned self] newPage in
|
||||||
|
if let newPage {
|
||||||
|
self.delegate?.profileHeader(self, selectedPageChangedTo: newPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vStack.addArrangedSubview(pagesSegmentedControl)
|
||||||
|
// equal inset on both sides, the leading inset is applied to the vStack
|
||||||
|
pagesSegmentedControl.widthAnchor.constraint(equalTo: vStack.widthAnchor, constant: -16).isActive = true
|
||||||
|
|
||||||
// the segemented control itself is only focusable when VoiceOver is in Group navigation mode,
|
// the segemented control itself is only focusable when VoiceOver is in Group navigation mode,
|
||||||
// so make it clear that to switch tabs the user needs to enter the group
|
// so make it clear that to switch tabs the user needs to enter the group
|
||||||
pagesSegmentedControl.accessibilityHint = "Enter group to select scope"
|
pagesSegmentedControl.accessibilityHint = "Enter group to select scope"
|
||||||
|
@ -264,11 +281,6 @@ class ProfileHeaderView: UIView {
|
||||||
delegate?.showLoadingLargeImage(url: header, cache: .headers, description: nil, animatingFrom: headerImageView)
|
delegate?.showLoadingLargeImage(url: header, cache: .headers, description: nil, animatingFrom: headerImageView)
|
||||||
}
|
}
|
||||||
|
|
||||||
@IBAction func postsSegmentedControlChanged(_ sender: UISegmentedControl) {
|
|
||||||
delegate?.profileHeader(self, selectedPostsIndexChangedTo: sender.selectedSegmentIndex)
|
|
||||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ProfileHeaderView: UIPointerInteractionDelegate {
|
extension ProfileHeaderView: UIPointerInteractionDelegate {
|
||||||
|
|
|
@ -69,42 +69,22 @@
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" horizontalHuggingPriority="249" verticalHuggingPriority="251" horizontalCompressionResistancePriority="749" scrollEnabled="NO" delaysContentTouches="NO" editable="NO" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1O8-2P-Gbf" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
|
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" horizontalHuggingPriority="249" verticalHuggingPriority="251" horizontalCompressionResistancePriority="749" scrollEnabled="NO" delaysContentTouches="NO" editable="NO" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1O8-2P-Gbf" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="382" height="259.5"/>
|
<rect key="frame" x="0.0" y="0.0" width="382" height="460"/>
|
||||||
<string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string>
|
<string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string>
|
||||||
<color key="textColor" systemColor="labelColor"/>
|
<color key="textColor" systemColor="labelColor"/>
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||||
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
||||||
</textView>
|
</textView>
|
||||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="vKC-m1-Sbs" customClass="ProfileFieldsView" customModule="Tusker" customModuleProvider="target">
|
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="vKC-m1-Sbs" customClass="ProfileFieldsView" customModule="Tusker" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="267.5" width="398" height="128"/>
|
<rect key="frame" x="0.0" y="468" width="398" height="128"/>
|
||||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="height" constant="128" placeholder="YES" id="xbR-M6-H0I"/>
|
<constraint firstAttribute="height" constant="128" placeholder="YES" id="xbR-M6-H0I"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
<segmentedControl opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="top" segmentControlStyle="plain" selectedSegmentIndex="0" translatesAutoresizingMaskIntoConstraints="NO" id="n1M-vM-Cj0">
|
|
||||||
<rect key="frame" x="0.0" y="403.5" width="382" height="185"/>
|
|
||||||
<segments>
|
|
||||||
<segment title="Posts"/>
|
|
||||||
<segment title="Posts and Replies"/>
|
|
||||||
<segment title="Media"/>
|
|
||||||
</segments>
|
|
||||||
<connections>
|
|
||||||
<action selector="postsSegmentedControlChanged:" destination="iN0-l3-epB" eventType="valueChanged" id="D6y-ZM-DwU"/>
|
|
||||||
</connections>
|
|
||||||
</segmentedControl>
|
|
||||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5ja-fK-Fqz">
|
|
||||||
<rect key="frame" x="0.0" y="595.5" width="398" height="0.5"/>
|
|
||||||
<color key="backgroundColor" systemColor="separatorColor"/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="height" constant="0.5" id="VwS-gV-q8M"/>
|
|
||||||
</constraints>
|
|
||||||
</view>
|
|
||||||
</subviews>
|
</subviews>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="vKC-m1-Sbs" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" id="0dI-ax-7eI"/>
|
<constraint firstItem="vKC-m1-Sbs" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" id="0dI-ax-7eI"/>
|
||||||
<constraint firstItem="n1M-vM-Cj0" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" constant="-16" id="9Ds-zl-acc"/>
|
|
||||||
<constraint firstItem="5ja-fK-Fqz" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" id="azv-le-93y"/>
|
|
||||||
<constraint firstItem="1O8-2P-Gbf" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" constant="-16" id="hnA-3G-B9B"/>
|
<constraint firstItem="1O8-2P-Gbf" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" constant="-16" id="hnA-3G-B9B"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
</stackView>
|
</stackView>
|
||||||
|
@ -124,6 +104,13 @@
|
||||||
</imageView>
|
</imageView>
|
||||||
</subviews>
|
</subviews>
|
||||||
</stackView>
|
</stackView>
|
||||||
|
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5ja-fK-Fqz">
|
||||||
|
<rect key="frame" x="16" y="861.5" width="398" height="0.5"/>
|
||||||
|
<color key="backgroundColor" systemColor="separatorColor"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstAttribute="height" constant="0.5" id="VwS-gV-q8M"/>
|
||||||
|
</constraints>
|
||||||
|
</view>
|
||||||
</subviews>
|
</subviews>
|
||||||
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
|
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
|
||||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||||
|
@ -133,9 +120,11 @@
|
||||||
<constraint firstItem="wT9-2J-uSY" firstAttribute="centerY" secondItem="dgG-dR-lSv" secondAttribute="bottom" id="7gb-T3-Xe7"/>
|
<constraint firstItem="wT9-2J-uSY" firstAttribute="centerY" secondItem="dgG-dR-lSv" secondAttribute="bottom" id="7gb-T3-Xe7"/>
|
||||||
<constraint firstItem="vcl-Gl-kXl" firstAttribute="top" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="8" symbolic="YES" id="7ss-Mf-YYH"/>
|
<constraint firstItem="vcl-Gl-kXl" firstAttribute="top" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="8" symbolic="YES" id="7ss-Mf-YYH"/>
|
||||||
<constraint firstItem="vcl-Gl-kXl" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="8ho-WU-MxW"/>
|
<constraint firstItem="vcl-Gl-kXl" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="8ho-WU-MxW"/>
|
||||||
|
<constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="5ja-fK-Fqz" secondAttribute="bottom" id="9ZS-Ey-eKd"/>
|
||||||
<constraint firstItem="jwU-EH-hmC" firstAttribute="top" secondItem="vcl-Gl-kXl" secondAttribute="bottom" id="9lx-Fn-M0U"/>
|
<constraint firstItem="jwU-EH-hmC" firstAttribute="top" secondItem="vcl-Gl-kXl" secondAttribute="bottom" id="9lx-Fn-M0U"/>
|
||||||
<constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="u4P-3i-gEq" secondAttribute="bottom" id="9zc-N2-mfI"/>
|
<constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="u4P-3i-gEq" secondAttribute="bottom" id="9zc-N2-mfI"/>
|
||||||
<constraint firstItem="bRJ-Xf-kc9" firstAttribute="bottom" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="-8" id="AXS-bG-20Q"/>
|
<constraint firstItem="bRJ-Xf-kc9" firstAttribute="bottom" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="-8" id="AXS-bG-20Q"/>
|
||||||
|
<constraint firstAttribute="trailing" secondItem="5ja-fK-Fqz" secondAttribute="trailing" id="EMk-dp-yJV"/>
|
||||||
<constraint firstItem="jwU-EH-hmC" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="TkY-oK-if4" secondAttribute="bottom" id="HBR-rg-RxO"/>
|
<constraint firstItem="jwU-EH-hmC" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="TkY-oK-if4" secondAttribute="bottom" id="HBR-rg-RxO"/>
|
||||||
<constraint firstItem="dgG-dR-lSv" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" id="VD1-yc-KSa"/>
|
<constraint firstItem="dgG-dR-lSv" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" id="VD1-yc-KSa"/>
|
||||||
<constraint firstItem="wT9-2J-uSY" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="WNS-AR-ff2"/>
|
<constraint firstItem="wT9-2J-uSY" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="WNS-AR-ff2"/>
|
||||||
|
@ -143,6 +132,7 @@
|
||||||
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="vcl-Gl-kXl" secondAttribute="trailing" constant="16" id="e38-Od-kPg"/>
|
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="vcl-Gl-kXl" secondAttribute="trailing" constant="16" id="e38-Od-kPg"/>
|
||||||
<constraint firstItem="u4P-3i-gEq" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="hgl-UR-o3W"/>
|
<constraint firstItem="u4P-3i-gEq" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="hgl-UR-o3W"/>
|
||||||
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="dgG-dR-lSv" secondAttribute="trailing" id="j0d-hY-815"/>
|
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="dgG-dR-lSv" secondAttribute="trailing" id="j0d-hY-815"/>
|
||||||
|
<constraint firstItem="5ja-fK-Fqz" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="jPG-WM-9km"/>
|
||||||
<constraint firstItem="jwU-EH-hmC" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="oGR-6M-gdd"/>
|
<constraint firstItem="jwU-EH-hmC" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="oGR-6M-gdd"/>
|
||||||
<constraint firstAttribute="trailing" secondItem="u4P-3i-gEq" secondAttribute="trailing" id="ph6-NT-A02"/>
|
<constraint firstAttribute="trailing" secondItem="u4P-3i-gEq" secondAttribute="trailing" id="ph6-NT-A02"/>
|
||||||
<constraint firstItem="u4P-3i-gEq" firstAttribute="top" secondItem="wT9-2J-uSY" secondAttribute="bottom" priority="999" constant="8" id="tKQ-6d-Z55"/>
|
<constraint firstItem="u4P-3i-gEq" firstAttribute="top" secondItem="wT9-2J-uSY" secondAttribute="bottom" priority="999" constant="8" id="tKQ-6d-Z55"/>
|
||||||
|
@ -157,9 +147,9 @@
|
||||||
<outlet property="lockImageView" destination="KNY-GD-beC" id="9EJ-iM-Eos"/>
|
<outlet property="lockImageView" destination="KNY-GD-beC" id="9EJ-iM-Eos"/>
|
||||||
<outlet property="moreButton" destination="bRJ-Xf-kc9" id="zIN-pz-L7y"/>
|
<outlet property="moreButton" destination="bRJ-Xf-kc9" id="zIN-pz-L7y"/>
|
||||||
<outlet property="noteTextView" destination="1O8-2P-Gbf" id="yss-zZ-uQ5"/>
|
<outlet property="noteTextView" destination="1O8-2P-Gbf" id="yss-zZ-uQ5"/>
|
||||||
<outlet property="pagesSegmentedControl" destination="n1M-vM-Cj0" id="TCU-ku-YZN"/>
|
|
||||||
<outlet property="relationshipLabel" destination="UF8-nI-KVj" id="dTe-DQ-eJV"/>
|
<outlet property="relationshipLabel" destination="UF8-nI-KVj" id="dTe-DQ-eJV"/>
|
||||||
<outlet property="usernameLabel" destination="1C3-Pd-QiL" id="57b-LQ-3pM"/>
|
<outlet property="usernameLabel" destination="1C3-Pd-QiL" id="57b-LQ-3pM"/>
|
||||||
|
<outlet property="vStack" destination="u4P-3i-gEq" id="EUC-d2-cQC"/>
|
||||||
</connections>
|
</connections>
|
||||||
<point key="canvasLocation" x="-590" y="117"/>
|
<point key="canvasLocation" x="-590" y="117"/>
|
||||||
</view>
|
</view>
|
||||||
|
|
|
@ -0,0 +1,228 @@
|
||||||
|
//
|
||||||
|
// ScrollingSegmentedControl.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/11/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecognizerDelegate, UIPointerInteractionDelegate {
|
||||||
|
|
||||||
|
private(set) var selectedOption: Value?
|
||||||
|
var options: [Option] = [] {
|
||||||
|
didSet {
|
||||||
|
createOptionViews()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var didSelectOption: ((Value?) -> Void)?
|
||||||
|
|
||||||
|
private let optionsStack = UIStackView()
|
||||||
|
private let selectedIndicatorView = UIView()
|
||||||
|
private var selectedIndicatorViewAlignmentConstraints: [NSLayoutConstraint] = []
|
||||||
|
private var changeSelectionPanRecognizer: UIGestureRecognizer!
|
||||||
|
private var selectedOptionAtStartOfPan: Value?
|
||||||
|
private lazy var selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator()
|
||||||
|
|
||||||
|
override var intrinsicContentSize: CGSize {
|
||||||
|
let buttonWidths = optionsStack.arrangedSubviews.map(\.intrinsicContentSize.width).reduce(0, +)
|
||||||
|
let spacing = (CGFloat(optionsStack.arrangedSubviews.count) - 1) * 8
|
||||||
|
// add 16 to account for the spacing around optionsStack
|
||||||
|
return CGSize(width: buttonWidths + spacing + 16, height: 44)
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
showsHorizontalScrollIndicator = false
|
||||||
|
|
||||||
|
optionsStack.axis = .horizontal
|
||||||
|
optionsStack.spacing = 8
|
||||||
|
optionsStack.distribution = .fillProportionally
|
||||||
|
optionsStack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(optionsStack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
optionsStack.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor, constant: 8),
|
||||||
|
optionsStack.trailingAnchor.constraint(equalTo: contentLayoutGuide.trailingAnchor, constant: -8),
|
||||||
|
optionsStack.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor),
|
||||||
|
optionsStack.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor),
|
||||||
|
optionsStack.heightAnchor.constraint(equalTo: heightAnchor),
|
||||||
|
|
||||||
|
// add 16 to account for the spacing around optionsStack
|
||||||
|
widthAnchor.constraint(lessThanOrEqualTo: optionsStack.widthAnchor, constant: 16),
|
||||||
|
])
|
||||||
|
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panRecognized))
|
||||||
|
self.changeSelectionPanRecognizer = panRecognizer
|
||||||
|
panRecognizer.delegate = self
|
||||||
|
optionsStack.addGestureRecognizer(panRecognizer)
|
||||||
|
optionsStack.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(optionTapped)))
|
||||||
|
optionsStack.addInteraction(UIPointerInteraction(delegate: self))
|
||||||
|
|
||||||
|
self.panGestureRecognizer.delegate = self
|
||||||
|
|
||||||
|
selectedIndicatorView.isHidden = true
|
||||||
|
selectedIndicatorView.backgroundColor = .tintColor
|
||||||
|
selectedIndicatorView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(selectedIndicatorView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
selectedIndicatorView.heightAnchor.constraint(equalToConstant: 4),
|
||||||
|
selectedIndicatorView.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createOptionViews() {
|
||||||
|
optionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
|
||||||
|
for (index, option) in options.enumerated() {
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = option.name
|
||||||
|
label.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
label.adjustsFontForContentSizeCategory = true
|
||||||
|
label.textColor = .secondaryLabel
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.accessibilityTraits = .button
|
||||||
|
label.accessibilityLabel = "\(option.name), \(index + 1) of \(options.count)"
|
||||||
|
optionsStack.addArrangedSubview(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSelectedOption(_ value: Value, animated: Bool) {
|
||||||
|
guard selectedOption != value,
|
||||||
|
options.contains(where: { $0.value == value }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectedOption != nil {
|
||||||
|
selectionChangedFeedbackGenerator.selectionChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedOption = value
|
||||||
|
didSelectOption?(value)
|
||||||
|
updateSelectedIndicatorView()
|
||||||
|
|
||||||
|
if animated && !selectedIndicatorView.isHidden {
|
||||||
|
let animator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 0.8) {
|
||||||
|
self.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
animator.startAnimation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSelectedIndicatorView() {
|
||||||
|
guard let selectedOption,
|
||||||
|
let selectedIndex = options.firstIndex(where: { $0.value == selectedOption }) else {
|
||||||
|
selectedIndicatorView.isHidden = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let selectedOptionView = optionsStack.arrangedSubviews[selectedIndex]
|
||||||
|
selectedIndicatorView.isHidden = false
|
||||||
|
NSLayoutConstraint.deactivate(selectedIndicatorViewAlignmentConstraints)
|
||||||
|
selectedIndicatorViewAlignmentConstraints = [
|
||||||
|
selectedIndicatorView.leadingAnchor.constraint(equalTo: selectedOptionView.leadingAnchor),
|
||||||
|
selectedIndicatorView.trailingAnchor.constraint(equalTo: selectedOptionView.trailingAnchor),
|
||||||
|
]
|
||||||
|
NSLayoutConstraint.activate(selectedIndicatorViewAlignmentConstraints)
|
||||||
|
|
||||||
|
for (index, optionView) in optionsStack.arrangedSubviews.enumerated() {
|
||||||
|
let label = optionView as! UILabel
|
||||||
|
label.textColor = index == selectedIndex ? .label : .secondaryLabel
|
||||||
|
label.accessibilityTraits = index == selectedIndex ? [.button, .selected] : .button
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Interaction
|
||||||
|
|
||||||
|
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||||
|
let beganOnSelectedOption: Bool
|
||||||
|
if let selectedIndex = options.firstIndex(where: { $0.value == selectedOption }),
|
||||||
|
optionsStack.arrangedSubviews[selectedIndex].frame.contains(self.panGestureRecognizer.location(in: optionsStack)) {
|
||||||
|
beganOnSelectedOption = true
|
||||||
|
} else {
|
||||||
|
beganOnSelectedOption = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// only begin changing selection if the gesutre started on the currently selected item
|
||||||
|
// otherwise, let the scroll view handle things
|
||||||
|
if gestureRecognizer == self.changeSelectionPanRecognizer {
|
||||||
|
return beganOnSelectedOption
|
||||||
|
} else {
|
||||||
|
return !beganOnSelectedOption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func panRecognized(_ recognizer: UIPanGestureRecognizer) {
|
||||||
|
let horizontalLocationInStack = CGPoint(x: recognizer.location(in: optionsStack).x, y: 0)
|
||||||
|
switch recognizer.state {
|
||||||
|
case .began:
|
||||||
|
selectedOptionAtStartOfPan = selectedOption
|
||||||
|
selectionChangedFeedbackGenerator.prepare()
|
||||||
|
|
||||||
|
case .changed:
|
||||||
|
if updateSelectionFor(location: horizontalLocationInStack) {
|
||||||
|
selectionChangedFeedbackGenerator.prepare()
|
||||||
|
}
|
||||||
|
|
||||||
|
case .ended:
|
||||||
|
if let selectedOptionAtStartOfPan {
|
||||||
|
self.selectedOptionAtStartOfPan = nil
|
||||||
|
if let selectedOption,
|
||||||
|
selectedOptionAtStartOfPan != selectedOption {
|
||||||
|
didSelectOption?(selectedOption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func optionTapped(_ recognizer: UITapGestureRecognizer) {
|
||||||
|
let location = recognizer.location(in: optionsStack)
|
||||||
|
if updateSelectionFor(location: location) {
|
||||||
|
didSelectOption?(selectedOption!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSelectionFor(location: CGPoint) -> Bool {
|
||||||
|
for (index, optionView) in optionsStack.arrangedSubviews.enumerated() where optionView.frame.contains(location) {
|
||||||
|
if selectedOption != options[index].value {
|
||||||
|
selectedOption = options[index].value
|
||||||
|
let animator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 0.8) {
|
||||||
|
self.updateSelectedIndicatorView()
|
||||||
|
self.scrollRectToVisible(optionView.frame.insetBy(dx: -16, dy: 0), animated: false)
|
||||||
|
self.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
animator.startAnimation()
|
||||||
|
selectionChangedFeedbackGenerator.selectionChanged()
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func pointerInteraction(_ interaction: UIPointerInteraction, regionFor request: UIPointerRegionRequest, defaultRegion: UIPointerRegion) -> UIPointerRegion? {
|
||||||
|
func distanceToAnyEdge(_ view: UIView) -> CGFloat {
|
||||||
|
min(abs(view.frame.minX - request.location.x), abs(view.frame.maxX - request.location.x))
|
||||||
|
}
|
||||||
|
let (view, index, _) = optionsStack.arrangedSubviews.enumerated().map { ($0.1, $0.0, distanceToAnyEdge($0.1)) }.min(by: { $0.2 < $1.2 })!
|
||||||
|
return UIPointerRegion(rect: view.frame.insetBy(dx: -8, dy: 0), identifier: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
|
||||||
|
let index = region.identifier as! Int
|
||||||
|
let optionView = optionsStack.arrangedSubviews[index]
|
||||||
|
return UIPointerStyle(effect: .hover(UITargetedPreview(view: optionView)))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Option: Hashable {
|
||||||
|
let value: Value
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
}
|
|
@ -231,16 +231,17 @@ class BaseStatusTableViewCell: UITableViewCell {
|
||||||
|
|
||||||
func updateUIForPreferences(account: AccountMO, status: StatusMO) {
|
func updateUIForPreferences(account: AccountMO, status: StatusMO) {
|
||||||
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
|
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
|
||||||
if Preferences.shared.blurAllMedia {
|
switch Preferences.shared.attachmentBlurMode {
|
||||||
attachmentsView.contentHidden = true
|
case .never:
|
||||||
} else if status.sensitive {
|
|
||||||
if !Preferences.shared.blurMediaBehindContentWarning && !status.spoilerText.isEmpty {
|
|
||||||
attachmentsView.contentHidden = false
|
|
||||||
} else {
|
|
||||||
attachmentsView.contentHidden = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
attachmentsView.contentHidden = false
|
attachmentsView.contentHidden = false
|
||||||
|
case .always:
|
||||||
|
attachmentsView.contentHidden = true
|
||||||
|
default:
|
||||||
|
if status.sensitive {
|
||||||
|
attachmentsView.contentHidden = status.spoilerText.isEmpty || Preferences.shared.blurMediaBehindContentWarning
|
||||||
|
} else {
|
||||||
|
attachmentsView.contentHidden = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateStatusIconsForPreferences(status)
|
updateStatusIconsForPreferences(status)
|
||||||
|
|
|
@ -148,16 +148,17 @@ extension StatusCollectionViewCell {
|
||||||
|
|
||||||
func baseUpdateUIForPreferences(status: StatusMO) {
|
func baseUpdateUIForPreferences(status: StatusMO) {
|
||||||
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.avatarImageViewSize
|
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.avatarImageViewSize
|
||||||
if Preferences.shared.blurAllMedia {
|
switch Preferences.shared.attachmentBlurMode {
|
||||||
contentContainer.attachmentsView.contentHidden = true
|
case .never:
|
||||||
} else if status.sensitive {
|
|
||||||
if !Preferences.shared.blurMediaBehindContentWarning && !status.spoilerText.isEmpty {
|
|
||||||
contentContainer.attachmentsView.contentHidden = false
|
|
||||||
} else {
|
|
||||||
contentContainer.attachmentsView.contentHidden = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
contentContainer.attachmentsView.contentHidden = false
|
contentContainer.attachmentsView.contentHidden = false
|
||||||
|
case .always:
|
||||||
|
contentContainer.attachmentsView.contentHidden = true
|
||||||
|
default:
|
||||||
|
if status.sensitive {
|
||||||
|
contentContainer.attachmentsView.contentHidden = status.spoilerText.isEmpty || Preferences.shared.blurMediaBehindContentWarning
|
||||||
|
} else {
|
||||||
|
contentContainer.attachmentsView.contentHidden = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let reblogButtonImage: UIImage
|
let reblogButtonImage: UIImage
|
||||||
|
|
|
@ -19,6 +19,7 @@ class StatusMetaIndicatorsView: UIView {
|
||||||
var secondaryAxisAlignment: Alignment = .leading
|
var secondaryAxisAlignment: Alignment = .leading
|
||||||
private var images: [UIImageView] = []
|
private var images: [UIImageView] = []
|
||||||
private var isUsingSingleAxis = false
|
private var isUsingSingleAxis = false
|
||||||
|
private var statusID: String?
|
||||||
|
|
||||||
private var needsSingleAxis: Bool {
|
private var needsSingleAxis: Bool {
|
||||||
traitCollection.preferredContentSizeCategory > .extraLarge
|
traitCollection.preferredContentSizeCategory > .extraLarge
|
||||||
|
@ -61,6 +62,11 @@ class StatusMetaIndicatorsView: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUI(status: StatusMO) {
|
func updateUI(status: StatusMO) {
|
||||||
|
guard statusID != status.id else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
statusID = status.id
|
||||||
|
|
||||||
var images: [UIImage] = []
|
var images: [UIImage] = []
|
||||||
|
|
||||||
if allowedIndicators.contains(.reply) && Preferences.shared.showIsStatusReplyIcon && status.inReplyToID != nil {
|
if allowedIndicators.contains(.reply) && Preferences.shared.showIsStatusReplyIcon && status.inReplyToID != nil {
|
||||||
|
|
|
@ -369,7 +369,13 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var str = AttributedString("\(status.account.displayOrUserName), ")
|
var str: AttributedString = ""
|
||||||
|
if let rebloggerID,
|
||||||
|
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
|
||||||
|
str += AttributedString("Reblogged by \(reblogger.displayNameWithoutCustomEmoji): ")
|
||||||
|
}
|
||||||
|
str += AttributedString(status.account.displayNameWithoutCustomEmoji)
|
||||||
|
str += ", "
|
||||||
if statusState.collapsed ?? false {
|
if statusState.collapsed ?? false {
|
||||||
if !status.spoilerText.isEmpty {
|
if !status.spoilerText.isEmpty {
|
||||||
str += AttributedString(status.spoilerText)
|
str += AttributedString(status.spoilerText)
|
||||||
|
@ -378,15 +384,37 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
str += "collapsed"
|
str += "collapsed"
|
||||||
} else {
|
} else {
|
||||||
str += AttributedString(contentTextView.attributedText)
|
str += AttributedString(contentTextView.attributedText)
|
||||||
}
|
|
||||||
|
|
||||||
if status.attachments.count > 0 {
|
if status.attachments.count > 0 {
|
||||||
// TODO: localize me
|
let includeDescriptions: Bool
|
||||||
str += AttributedString(", \(status.attachments.count) attachment\(status.attachments.count > 1 ? "s" : "")")
|
switch Preferences.shared.attachmentBlurMode {
|
||||||
}
|
case .useStatusSetting:
|
||||||
if status.poll != nil {
|
includeDescriptions = !Preferences.shared.blurMediaBehindContentWarning || status.spoilerText.isEmpty
|
||||||
str += ", poll"
|
case .always:
|
||||||
|
includeDescriptions = true
|
||||||
|
case .never:
|
||||||
|
includeDescriptions = false
|
||||||
|
}
|
||||||
|
if includeDescriptions {
|
||||||
|
if status.attachments.count == 1 {
|
||||||
|
let attachment = status.attachments[0]
|
||||||
|
let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description"
|
||||||
|
str += AttributedString(", attachment: \(desc)")
|
||||||
|
} else {
|
||||||
|
for (index, attachment) in status.attachments.enumerated() {
|
||||||
|
let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description"
|
||||||
|
str += AttributedString(", attachment \(index + 1): \(desc)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
str += AttributedString(", \(status.attachments.count) attachment\(status.attachments.count == 1 ? "" : "s")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if status.poll != nil {
|
||||||
|
str += ", poll"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
str += AttributedString(", \(status.createdAt.formatted(.relative(presentation: .numeric)))")
|
str += AttributedString(", \(status.createdAt.formatted(.relative(presentation: .numeric)))")
|
||||||
if status.visibility < .unlisted {
|
if status.visibility < .unlisted {
|
||||||
str += AttributedString(", \(status.visibility.displayName)")
|
str += AttributedString(", \(status.visibility.displayName)")
|
||||||
|
@ -394,10 +422,6 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
if status.localOnly {
|
if status.localOnly {
|
||||||
str += ", local"
|
str += ", local"
|
||||||
}
|
}
|
||||||
if let rebloggerID,
|
|
||||||
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
|
|
||||||
str += AttributedString(", reblogged by \(reblogger.displayOrUserName)")
|
|
||||||
}
|
|
||||||
return NSAttributedString(str)
|
return NSAttributedString(str)
|
||||||
}
|
}
|
||||||
set {}
|
set {}
|
||||||
|
|
|
@ -254,7 +254,13 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
|
||||||
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var str = AttributedString("\(status.account.displayOrUserName), ")
|
var str: AttributedString = ""
|
||||||
|
if let rebloggerID,
|
||||||
|
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
|
||||||
|
str += AttributedString("Reblogged by \(reblogger.displayNameWithoutCustomEmoji): ")
|
||||||
|
}
|
||||||
|
str += AttributedString(status.account.displayNameWithoutCustomEmoji)
|
||||||
|
str += ", "
|
||||||
if statusState.collapsed ?? false {
|
if statusState.collapsed ?? false {
|
||||||
if !status.spoilerText.isEmpty {
|
if !status.spoilerText.isEmpty {
|
||||||
str += AttributedString(status.spoilerText)
|
str += AttributedString(status.spoilerText)
|
||||||
|
@ -263,15 +269,37 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
|
||||||
str += "collapsed"
|
str += "collapsed"
|
||||||
} else {
|
} else {
|
||||||
str += AttributedString(contentTextView.attributedText)
|
str += AttributedString(contentTextView.attributedText)
|
||||||
}
|
|
||||||
|
|
||||||
if status.attachments.count > 0 {
|
if status.attachments.count > 0 {
|
||||||
// TODO: localize me
|
let includeDescriptions: Bool
|
||||||
str += AttributedString(", \(status.attachments.count) attachment\(status.attachments.count > 1 ? "s" : "")")
|
switch Preferences.shared.attachmentBlurMode {
|
||||||
}
|
case .useStatusSetting:
|
||||||
if status.poll != nil {
|
includeDescriptions = !Preferences.shared.blurMediaBehindContentWarning || status.spoilerText.isEmpty
|
||||||
str += ", poll"
|
case .always:
|
||||||
|
includeDescriptions = true
|
||||||
|
case .never:
|
||||||
|
includeDescriptions = false
|
||||||
|
}
|
||||||
|
if includeDescriptions {
|
||||||
|
if status.attachments.count == 1 {
|
||||||
|
let attachment = status.attachments[0]
|
||||||
|
let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description"
|
||||||
|
str += AttributedString(", attachment: \(desc)")
|
||||||
|
} else {
|
||||||
|
for (index, attachment) in status.attachments.enumerated() {
|
||||||
|
let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description"
|
||||||
|
str += AttributedString(", attachment \(index + 1): \(desc)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
str += AttributedString(", \(status.attachments.count) attachment\(status.attachments.count == 1 ? "" : "s")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if status.poll != nil {
|
||||||
|
str += ", poll"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
str += AttributedString(", \(status.createdAt.formatted(.relative(presentation: .numeric)))")
|
str += AttributedString(", \(status.createdAt.formatted(.relative(presentation: .numeric)))")
|
||||||
if status.visibility < .unlisted {
|
if status.visibility < .unlisted {
|
||||||
str += AttributedString(", \(status.visibility.displayName)")
|
str += AttributedString(", \(status.visibility.displayName)")
|
||||||
|
@ -279,10 +307,6 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
|
||||||
if status.localOnly {
|
if status.localOnly {
|
||||||
str += ", local"
|
str += ", local"
|
||||||
}
|
}
|
||||||
if let rebloggerID,
|
|
||||||
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
|
|
||||||
str += AttributedString(", reblogged by \(reblogger.displayOrUserName)")
|
|
||||||
}
|
|
||||||
return NSAttributedString(str)
|
return NSAttributedString(str)
|
||||||
}
|
}
|
||||||
set {}
|
set {}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue