Compare commits

...

31 Commits

Author SHA1 Message Date
Shadowfacts 5ee140cdab Bump build number and update changelog 2022-12-13 21:26:28 -05:00
Shadowfacts ff4dff1147 Fix status icons flashing blue during expand/collapse
Closes #209
2022-12-13 20:56:08 -05:00
Shadowfacts ba1eed7a85 Add pointer effect to custom alert actions
Closes #306
2022-12-13 20:36:18 -05:00
Shadowfacts 0c9f6e02bd Fix controls reappearing when swiping between pages in gallery 2022-12-13 14:14:13 -05:00
Shadowfacts 565d17970f Make attachment description scrollable beyond a certain height
Closes #168
2022-12-13 14:07:16 -05:00
Shadowfacts dc3c2d027c Prevent statuses which are in the persisted timeline state from being pruned 2022-12-13 13:31:34 -05:00
Shadowfacts ba2c34fdd6 Persist timeline state using CoreData, rather than NSUserActivity
This allows persisting state for all the primary timelines, and across
all accounts.

Closes #297
Closes #293
2022-12-13 13:31:34 -05:00
Shadowfacts 3691c3f483 Actually encode the swipe action prefs 2022-12-12 23:09:18 -05:00
Shadowfacts 9c103103e8 Fix ToastableViewController automatic scroll view detection not handling collection views 2022-12-12 22:57:33 -05:00
Shadowfacts 382d8ef2c8 Fix Trending Posts appearing to reload forever 2022-12-12 22:51:50 -05:00
Shadowfacts 2891f47cb3 Fix statuses from the wrong timeline being restored into Home (again) 2022-12-12 22:47:16 -05:00
Shadowfacts 3c80ec8b43 Allow saving or following hashtag from Add screen 2022-12-12 22:06:55 -05:00
Shadowfacts 478ba3db28 Include followed hashtags in Explore and sidebar 2022-12-12 22:02:07 -05:00
Shadowfacts f96cd1b5e2 Copy showStatusesAutomatically when selecting conversation expand thread item
Closes #303
2022-12-12 21:06:05 -05:00
Shadowfacts 7f4ab57a1d Fix <li> bullets/numbers appearing black in dark mode
Closes #304
2022-12-12 21:00:12 -05:00
Shadowfacts 8caf93bf0a Add ScrollingSegmentedControl, and home/notifs/profiles to use it 2022-12-12 20:57:38 -05:00
Shadowfacts 9c4b68b09e Reorganize gestures 2022-12-12 20:56:14 -05:00
Shadowfacts b49e8d0279 Move Pachyderm to Packages folder 2022-12-11 14:25:25 -05:00
Shadowfacts 71a57e9859 Fix images copied from Safari pasting as URLs
Closes #301
2022-12-11 12:54:25 -05:00
Shadowfacts 081ef16e5e Fix My Profile item in sidebar not updating when avatar style changes
Closes #298
2022-12-10 19:41:45 -05:00
Shadowfacts b3ec259ce9 Fix status bar scroll to top not working in single-column navigation on iPad
Closes #296
2022-12-10 19:40:05 -05:00
Shadowfacts 4f48514d1a Actually only restore existing statuses 2022-12-08 20:15:12 -05:00
Shadowfacts f96acd33f2 Tweak timeline status VO labels to only include attachment text when not blurred 2022-12-06 22:29:03 -05:00
Shadowfacts cde061c77a Fix custom emoji not being stripped from usernames in VoiceOver labels 2022-12-06 22:26:08 -05:00
Shadowfacts a79b3cfd70 Fix gallery controls not being accessible, fix escape gesture not working
Closes #292
2022-12-06 22:21:59 -05:00
Shadowfacts 9a35f96c75 VoiceOver: Include attachment descriptions in timeline statuses
Closes #291
2022-12-06 22:14:23 -05:00
Shadowfacts 60767c6a7e Profile Directory screen VoiceOver improvements
Add label to filter button (and change icon to match other filters)

Make each profile a single accessibility element
2022-12-06 21:54:17 -05:00
Shadowfacts 57668886b2 Fix crash when scrolling through Local/Federated timeline with VoiceOver
It seems that the accessibility scroll mechanism does something like:
1. Find the next IndexPath to focus
2. Scroll to make it visible
3. Focus that cell

But because the timeline description cell is removed during the scroll,
the IndexPath that the accessibility system wants to focus becomes
invalid between steps 2 and 3, causing a crash when trying to focus it.

As a workaround, only remove the timeline description _item_ rather than
the header section so that section indices aren't affected.

Closes #290
2022-12-06 21:46:32 -05:00
Shadowfacts ffb5c76f7c Add preference to never blur attachments 2022-12-06 21:12:58 -05:00
Shadowfacts 00e8dd6345 Fix crash when previeiwng non-HTTP(S) link 2022-12-06 10:58:13 -05:00
Shadowfacts 7904462920 Fix serializing the nodeinfo version instead of the software version in breadcrumb 2022-12-05 22:24:33 -05:00
101 changed files with 963 additions and 317 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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