Compare commits

..

20 Commits

Author SHA1 Message Date
Shadowfacts aced0a63c9
Bump build number and update changelog 2021-08-15 22:43:32 -04:00
Shadowfacts 1e54235ff5 Hide public timeline description when user begins scrolling rather than
after cell moves offscreen

Fixes description getting dismissed prematurely on iOS 14 and hitching
when the cell moves offscreen
2021-08-15 22:29:14 -04:00
Shadowfacts e6e5554edf Fix fast account switcher animation weirdness when 1 account only 2021-08-15 19:29:26 -04:00
Shadowfacts 9026f487ec Convert notifications to use DiffableTimelineLikeTableViewController 2021-08-15 19:25:29 -04:00
Shadowfacts c0097ba752 Fix potential race condition with DiffableTimelineLikeTableViewController 2021-08-15 18:44:23 -04:00
Shadowfacts f109253bba Show toast when there are no new posts 2021-08-15 18:27:30 -04:00
Shadowfacts 1fda4248ec Add activity indicator to instance selector 2021-08-15 11:02:19 -04:00
Shadowfacts 7781c5252b Display toast on load errors 2021-08-15 10:37:37 -04:00
Shadowfacts 7f4bf52050 Add toast system 2021-08-15 10:37:20 -04:00
Shadowfacts ba0d179de5 Fix AccountSwtichingContainerViewController not sending sceneDidEnterBackground to children 2021-08-15 10:37:04 -04:00
Shadowfacts 71b6f1bdf0 Alphabetize things in Xcode 2021-08-14 18:27:22 -04:00
Shadowfacts 09ec4a920c
Fix retain cycle in ProfileViewController 2021-08-14 10:25:32 -04:00
Shadowfacts 7edf0fdb93
Fix crash when replying to post with preformatted text 2021-08-12 21:03:11 -04:00
Shadowfacts 99e06441f0
Fix crash when getting account relationship fails
UIDeferredMenuElement completion handler should only be called from the
main thread
2021-08-12 19:41:00 -04:00
Shadowfacts 85e1e131f6
Fix crash when fetching recommended instances fails 2021-08-12 19:36:28 -04:00
Shadowfacts 1d79918a94
Fix crash when refreshing before anything is loaded 2021-08-08 10:26:51 -04:00
Shadowfacts 340d13b1fa
Fix crash when reloading list timelines 2021-08-08 10:19:18 -04:00
Shadowfacts cf1000a4df
Fix loadOlder being called excessively on public timelines 2021-08-08 10:09:38 -04:00
Shadowfacts b781b56efd
Add public timeline descriptions 2021-08-08 10:09:28 -04:00
Shadowfacts 10a8a85bfc
Enable object lifetime optimization 2021-08-07 11:06:07 -04:00
19 changed files with 1090 additions and 244 deletions

View File

@ -1,5 +1,19 @@
# Changelog # Changelog
## 2021.1 (22)
This is the first public beta build of Tusker, so if you're just joining us, welcome! Not too many new features this build, mostly bugfixes, so test everything and generally use the app.
Features/Improvements:
- Add timeline descriptions the first time you view federated/local
- Show messages when loading posts fails or when there are no newer posts
Bugfixes:
- Fix crash after editing lists
- Fix crash when refreshing before anything is loaded
- Fix crash when fetching recommended instances fails
- Fix crash when replying to posts with code formatting
- Fix crash when changing preferences after switching accounts
## 2021.1 (21) ## 2021.1 (21)
This is a quick follow-up to the previous build with fixes for a couple major crashes. Unfortunately, due to a bug in iOS 14, the Disable Infinite Scrolling preference now requires the iOS 15 beta to use. It may return in a future build if I can find a workaround, but it's disabled in the meantime. This is a quick follow-up to the previous build with fixes for a couple major crashes. Unfortunately, due to a bug in iOS 14, the Disable Infinite Scrolling preference now requires the iOS 15 beta to use. It may return in a future build if I can find a workaround, but it's disabled in the meantime.

View File

@ -24,8 +24,6 @@
<dict> <dict>
<key>NSExtensionAttributes</key> <key>NSExtensionAttributes</key>
<dict> <dict>
<key>NSExtensionJavaScriptPreprocessingFile</key>
<string>Action</string>
<key>NSExtensionActivationRule</key> <key>NSExtensionActivationRule</key>
<dict> <dict>
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key> <key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>
@ -35,6 +33,8 @@
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key> <key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer> <integer>1</integer>
</dict> </dict>
<key>NSExtensionJavaScriptPreprocessingFile</key>
<string>Action</string>
<key>NSExtensionServiceAllowsFinderPreviewItem</key> <key>NSExtensionServiceAllowsFinderPreviewItem</key>
<true/> <true/>
<key>NSExtensionServiceAllowsTouchBarItem</key> <key>NSExtensionServiceAllowsTouchBarItem</key>

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
public class NotificationGroup { public class NotificationGroup: Identifiable, Hashable {
public let notifications: [Notification] public let notifications: [Notification]
public let id: String public let id: String
public let kind: Notification.Kind public let kind: Notification.Kind
@ -26,6 +26,14 @@ public class NotificationGroup {
} }
} }
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {
return lhs.id == rhs.id
}
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] { public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
var groups = [[Notification]]() var groups = [[Notification]]()
for notification in notifications { for notification in notifications {
@ -50,5 +58,3 @@ public class NotificationGroup {
} }
} }
extension NotificationGroup: Identifiable {}

View File

@ -134,11 +134,16 @@
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */; }; D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */; };
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */; }; D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */; };
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */; }; D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */; };
D6420AEE26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6420AEC26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift */; };
D6420AEF26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6420AED26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.xib */; };
D6434EB3215B1856001A919A /* XCBRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6434EB2215B1856001A919A /* XCBRequest.swift */; }; D6434EB3215B1856001A919A /* XCBRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6434EB2215B1856001A919A /* XCBRequest.swift */; };
D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C955213B365700269FB5 /* LargeImageExpandAnimationController.swift */; }; D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C955213B365700269FB5 /* LargeImageExpandAnimationController.swift */; };
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */; }; D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */; };
D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */; }; D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */; };
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */; }; D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */; };
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9026C80DC600FC57FB /* ToastView.swift */; };
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */; };
D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */; };
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18523C1253A000D0238 /* AssetPreviewViewController.swift */; }; D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18523C1253A000D0238 /* AssetPreviewViewController.swift */; };
D64BC18823C1640A000D0238 /* PinStatusActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18723C1640A000D0238 /* PinStatusActivity.swift */; }; D64BC18823C1640A000D0238 /* PinStatusActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18723C1640A000D0238 /* PinStatusActivity.swift */; };
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18923C16487000D0238 /* UnpinStatusActivity.swift */; }; D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18923C16487000D0238 /* UnpinStatusActivity.swift */; };
@ -535,11 +540,16 @@
D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ProfileHeaderView.xib; sourceTree = "<group>"; }; D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ProfileHeaderView.xib; sourceTree = "<group>"; };
D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = "<group>"; }; D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = "<group>"; };
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTextAttachment.swift; sourceTree = "<group>"; }; D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTextAttachment.swift; sourceTree = "<group>"; };
D6420AEC26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionTableViewCell.swift; sourceTree = "<group>"; };
D6420AED26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = PublicTimelineDescriptionTableViewCell.xib; sourceTree = "<group>"; };
D6434EB2215B1856001A919A /* XCBRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBRequest.swift; sourceTree = "<group>"; }; D6434EB2215B1856001A919A /* XCBRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBRequest.swift; sourceTree = "<group>"; };
D646C955213B365700269FB5 /* LargeImageExpandAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageExpandAnimationController.swift; sourceTree = "<group>"; }; D646C955213B365700269FB5 /* LargeImageExpandAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageExpandAnimationController.swift; sourceTree = "<group>"; };
D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageShrinkAnimationController.swift; sourceTree = "<group>"; }; D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageShrinkAnimationController.swift; sourceTree = "<group>"; };
D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageInteractionController.swift; sourceTree = "<group>"; }; D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageInteractionController.swift; sourceTree = "<group>"; };
D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentPreviewViewController.swift; sourceTree = "<group>"; }; D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentPreviewViewController.swift; sourceTree = "<group>"; };
D64AAE9026C80DC600FC57FB /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = "<group>"; };
D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastableViewController.swift; sourceTree = "<group>"; };
D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastConfiguration.swift; sourceTree = "<group>"; };
D64BC18523C1253A000D0238 /* AssetPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPreviewViewController.swift; sourceTree = "<group>"; }; D64BC18523C1253A000D0238 /* AssetPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPreviewViewController.swift; sourceTree = "<group>"; };
D64BC18723C1640A000D0238 /* PinStatusActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinStatusActivity.swift; sourceTree = "<group>"; }; D64BC18723C1640A000D0238 /* PinStatusActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinStatusActivity.swift; sourceTree = "<group>"; };
D64BC18923C16487000D0238 /* UnpinStatusActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnpinStatusActivity.swift; sourceTree = "<group>"; }; D64BC18923C16487000D0238 /* UnpinStatusActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnpinStatusActivity.swift; sourceTree = "<group>"; };
@ -1058,27 +1068,27 @@
D641C780213DD7C4004B4513 /* Screens */ = { D641C780213DD7C4004B4513 /* Screens */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6C693FA2162FE5D007D6A6D /* Utilities */, D6A3BC822321F69400FD64D5 /* Account List */,
D6F2E960249E772F005846BB /* Crash Reporter */,
D641C782213DD7F0004B4513 /* Main */,
D641C783213DD7FE004B4513 /* Onboarding */,
D6A4DCC92553666600D9DE31 /* Fast Account Switcher */,
D641C781213DD7DD004B4513 /* Timeline */,
D641C784213DD819004B4513 /* Profile */,
D641C785213DD83B004B4513 /* Conversation */,
D641C786213DD852004B4513 /* Notifications */,
D641C787213DD862004B4513 /* Compose */,
D6B053A023BD2BED00A066FA /* Asset Picker */, D6B053A023BD2BED00A066FA /* Asset Picker */,
0411610522B457290030A9B7 /* Attachment Gallery */,
D627944823A6AD5100D38C68 /* Bookmarks */,
D641C787213DD862004B4513 /* Compose */,
D641C785213DD83B004B4513 /* Conversation */,
D6F2E960249E772F005846BB /* Crash Reporter */,
D627FF77217E94F200CC0648 /* Drafts */, D627FF77217E94F200CC0648 /* Drafts */,
D627943C23A5635D00D38C68 /* Explore */, D627943C23A5635D00D38C68 /* Explore */,
D6BC9DD8232D8BCA002CA326 /* Search */, D6A4DCC92553666600D9DE31 /* Fast Account Switcher */,
D627944B23A9A02400D38C68 /* Lists */,
D641C788213DD86D004B4513 /* Large Image */, D641C788213DD86D004B4513 /* Large Image */,
0411610522B457290030A9B7 /* Attachment Gallery */, D627944B23A9A02400D38C68 /* Lists */,
D6A3BC822321F69400FD64D5 /* Account List */, D641C782213DD7F0004B4513 /* Main */,
D6A3BC8C2321FF9B00FD64D5 /* Status Action Account List */, D641C786213DD852004B4513 /* Notifications */,
D627944823A6AD5100D38C68 /* Bookmarks */, D641C783213DD7FE004B4513 /* Onboarding */,
D641C789213DD87E004B4513 /* Preferences */, D641C789213DD87E004B4513 /* Preferences */,
D641C784213DD819004B4513 /* Profile */,
D6BC9DD8232D8BCA002CA326 /* Search */,
D6A3BC8C2321FF9B00FD64D5 /* Status Action Account List */,
D641C781213DD7DD004B4513 /* Timeline */,
D6C693FA2162FE5D007D6A6D /* Utilities */,
); );
path = Screens; path = Screens;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1240,6 +1250,15 @@
path = Notifications; path = Notifications;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D6420AEB26BED17500ED8175 /* Timeline Description Cell */ = {
isa = PBXGroup;
children = (
D6420AEC26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift */,
D6420AED26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.xib */,
);
path = "Timeline Description Cell";
sourceTree = "<group>";
};
D646C954213B364600269FB5 /* Transitions */ = { D646C954213B364600269FB5 /* Transitions */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -1250,6 +1269,16 @@
path = Transitions; path = Transitions;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D64AAE8F26C80DB600FC57FB /* Toast */ = {
isa = PBXGroup;
children = (
D64AAE9026C80DC600FC57FB /* ToastView.swift */,
D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */,
D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */,
);
path = Toast;
sourceTree = "<group>";
};
D65A37F221472F300087646E /* Frameworks */ = { D65A37F221472F300087646E /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -1441,35 +1470,37 @@
D6BED1722126661300F02DA0 /* Views */ = { D6BED1722126661300F02DA0 /* Views */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D620483323D3801D008A63EF /* LinkTextView.swift */, D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
D620483523D38075008A63EF /* ContentTextView.swift */,
D620483723D38190008A63EF /* StatusContentTextView.swift */,
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */, D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */,
D620483523D38075008A63EF /* ContentTextView.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
D6969E9F240C8384002843CE /* EmojiLabel.swift */, D6969E9F240C8384002843CE /* EmojiLabel.swift */,
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */, D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */,
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */,
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */, D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */,
04ED00B021481ED800567C53 /* SteppedProgressView.swift */, D620483323D3801D008A63EF /* LinkTextView.swift */,
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */, D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */, D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */, D620483723D38190008A63EF /* StatusContentTextView.swift */,
04ED00B021481ED800567C53 /* SteppedProgressView.swift */,
D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */, D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */,
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */, D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */,
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */, D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */, D6A3BC872321F78000FD64D5 /* Account Cell */,
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */,
D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */,
D67C57A721E2649B00C3118B /* Account Detail */, D67C57A721E2649B00C3118B /* Account Detail */,
D626494023C122C800612E6E /* Asset Picker */, D626494023C122C800612E6E /* Asset Picker */,
D61959D0241E842400A37B8E /* Draft Cell */,
D641C78A213DD926004B4513 /* Status */,
D6C7D27B22B6EBE200071952 /* Attachments */, D6C7D27B22B6EBE200071952 /* Attachments */,
D623A53B2635F4E20095BD04 /* Poll */, D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */,
D641C78B213DD92F004B4513 /* Profile Header */, D61959D0241E842400A37B8E /* Draft Cell */,
D641C78C213DD937004B4513 /* Notifications */,
D6A3BC872321F78000FD64D5 /* Account Cell */,
D611C2CC232DC5FC00C86A49 /* Hashtag Cell */, D611C2CC232DC5FC00C86A49 /* Hashtag Cell */,
D61AC1DA232EA43100C54D2D /* Instance Cell */, D61AC1DA232EA43100C54D2D /* Instance Cell */,
D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */, D641C78C213DD937004B4513 /* Notifications */,
D623A53B2635F4E20095BD04 /* Poll */,
D641C78B213DD92F004B4513 /* Profile Header */,
D641C78A213DD926004B4513 /* Status */,
D64AAE8F26C80DB600FC57FB /* Toast */,
D6420AEB26BED17500ED8175 /* Timeline Description Cell */,
); );
path = Views; path = Views;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1477,24 +1508,24 @@
D6C693FA2162FE5D007D6A6D /* Utilities */ = { D6C693FA2162FE5D007D6A6D /* Utilities */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */,
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */,
D6E0DC8D216EDF1E00369478 /* Previewing.swift */,
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */,
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */,
D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */,
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */,
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */,
D693DE5823FE24300061E07D /* InteractivePushTransition.swift */,
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */,
D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */, D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */,
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */,
D65234C8256189D0001AF9CF /* TimelineLikeTableViewController.swift */,
D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */, D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */,
D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */, D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */,
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */,
D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */,
D693DE5823FE24300061E07D /* InteractivePushTransition.swift */,
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */,
D6E0DC8D216EDF1E00369478 /* Previewing.swift */,
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */,
D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */,
D6A6C10425B6138A00298D0F /* StatusTablePrefetching.swift */, D6A6C10425B6138A00298D0F /* StatusTablePrefetching.swift */,
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */,
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */,
D65234C8256189D0001AF9CF /* TimelineLikeTableViewController.swift */,
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */,
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */,
); );
path = Utilities; path = Utilities;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1541,36 +1572,36 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6E4885C24A2890C0011C13E /* Tusker.entitlements */, D6E4885C24A2890C0011C13E /* Tusker.entitlements */,
D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */,
D6D4DDDB212518A200E1C4BB /* Info.plist */,
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */, D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */, D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */, D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */,
D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */, D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */,
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */,
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D64D0AAC2128D88B005A6F37 /* LocalData.swift */, D64D0AAC2128D88B005A6F37 /* LocalData.swift */,
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */, D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */, D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */,
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */, D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */,
D6E57FA525C26FAB00341037 /* Localizable.stringsdict */,
D67B506B250B28FF00FAECFB /* Vendor */,
D6F1F84E2193B9BE00F5FE67 /* Caching */,
D6757A7A2157E00100721E32 /* XCallbackURL */,
D62D241E217AA46B005076CC /* Shortcuts */,
D663626021360A9600C9CBA2 /* Preferences */,
D6AEBB3F2321640F00E5038B /* Activities */,
D667E5F62135C2ED0057A976 /* Extensions */,
D61959D2241E846D00A37B8E /* Models */,
D6370B9924421FE00092A7FF /* CoreData */,
D6F953F121251A2F00CF0F2B /* Controllers */,
D641C780213DD7C4004B4513 /* Screens */,
D6BED1722126661300F02DA0 /* Views */,
D6D4DDD6212518A200E1C4BB /* Assets.xcassets */, D6D4DDD6212518A200E1C4BB /* Assets.xcassets */,
D6AEBB3F2321640F00E5038B /* Activities */,
D6F1F84E2193B9BE00F5FE67 /* Caching */,
D6F953F121251A2F00CF0F2B /* Controllers */,
D6370B9924421FE00092A7FF /* CoreData */,
D667E5F62135C2ED0057A976 /* Extensions */,
D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */, D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */,
D6D4DDDB212518A200E1C4BB /* Info.plist */, D6E57FA525C26FAB00341037 /* Localizable.stringsdict */,
D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */, D61959D2241E846D00A37B8E /* Models */,
D663626021360A9600C9CBA2 /* Preferences */,
D641C780213DD7C4004B4513 /* Screens */,
D62D241E217AA46B005076CC /* Shortcuts */,
D67B506B250B28FF00FAECFB /* Vendor */,
D6BED1722126661300F02DA0 /* Views */,
D6757A7A2157E00100721E32 /* XCallbackURL */,
); );
path = Tusker; path = Tusker;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1886,6 +1917,7 @@
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */, D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */,
D6C82B5725C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib in Resources */, D6C82B5725C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib in Resources */,
D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */, D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */,
D6420AEF26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.xib in Resources */,
D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */, D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */,
D6B053AC23BD2F1400A066FA /* AssetCollectionViewCell.xib in Resources */, D6B053AC23BD2F1400A066FA /* AssetCollectionViewCell.xib in Resources */,
D6D4DDD7212518A200E1C4BB /* Assets.xcassets in Resources */, D6D4DDD7212518A200E1C4BB /* Assets.xcassets in Resources */,
@ -2052,6 +2084,7 @@
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */, D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */,
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */, D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */,
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */, D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */,
D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */, D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */,
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */, 0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */,
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */, D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */,
@ -2133,6 +2166,7 @@
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */, D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */,
D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */, D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */,
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */, D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */,
D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */,
D6093FB025BE0B01004811E6 /* TrendingHashtagTableViewCell.swift in Sources */, D6093FB025BE0B01004811E6 /* TrendingHashtagTableViewCell.swift in Sources */,
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */, D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */,
D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */, D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */,
@ -2147,6 +2181,8 @@
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */, D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */,
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */, D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */,
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */, D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */,
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */,
D6420AEE26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift in Sources */,
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */, D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */, D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */,
D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */, D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */,
@ -2509,6 +2545,7 @@
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES;
}; };
name = Debug; name = Debug;
}; };
@ -2565,6 +2602,7 @@
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES;
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
}; };
name = Release; name = Release;
@ -2577,7 +2615,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 = 21; CURRENT_PROJECT_VERSION = 22;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2610,7 +2648,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 = 21; CURRENT_PROJECT_VERSION = 22;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2719,7 +2757,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 = 21; CURRENT_PROJECT_VERSION = 22;
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;
@ -2746,7 +2784,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 = 21; CURRENT_PROJECT_VERSION = 22;
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

@ -67,6 +67,9 @@ class Preferences: Codable, ObservableObject {
self.silentActions = try container.decode([String: Permission].self, forKey: .silentActions) self.silentActions = try container.decode([String: Permission].self, forKey: .silentActions)
self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType) self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType)
self.hasShownLocalTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownLocalTimelineDescription) ?? false
self.hasShownFederatedTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownFederatedTimelineDescription) ?? false
} }
func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
@ -102,6 +105,9 @@ class Preferences: Codable, ObservableObject {
try container.encode(silentActions, forKey: .silentActions) try container.encode(silentActions, forKey: .silentActions)
try container.encode(statusContentType, forKey: .statusContentType) try container.encode(statusContentType, forKey: .statusContentType)
try container.encode(hasShownLocalTimelineDescription, forKey: .hasShownLocalTimelineDescription)
try container.encode(hasShownFederatedTimelineDescription, forKey: .hasShownFederatedTimelineDescription)
} }
// MARK: Appearance // MARK: Appearance
@ -141,6 +147,10 @@ class Preferences: Codable, ObservableObject {
@Published var silentActions: [String: Permission] = [:] @Published var silentActions: [String: Permission] = [:]
@Published var statusContentType: StatusContentType = .plain @Published var statusContentType: StatusContentType = .plain
// MARK:
@Published var hasShownLocalTimelineDescription = false
@Published var hasShownFederatedTimelineDescription = false
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case theme case theme
case avatarStyle case avatarStyle
@ -172,6 +182,9 @@ class Preferences: Codable, ObservableObject {
case silentActions case silentActions
case statusContentType case statusContentType
case hasShownLocalTimelineDescription
case hasShownFederatedTimelineDescription
} }
} }

View File

@ -73,12 +73,12 @@ class FastAccountSwitcherViewController: UIViewController {
accountView.alpha = 0 accountView.alpha = 0
accountView.transform = CGAffineTransform(scaleX: 1.2, y: 1.2) accountView.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
UIView.addKeyframe(withRelativeStartTime: relStart, relativeDuration: relDuration) { UIView.addKeyframe(withRelativeStartTime: relStart, relativeDuration: relDuration / 2) {
accountView.alpha = 1 accountView.alpha = 1
accountView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9) accountView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
} }
UIView.addKeyframe(withRelativeStartTime: relStart + relDuration, relativeDuration: relDuration) { UIView.addKeyframe(withRelativeStartTime: relStart + relDuration / 2, relativeDuration: relDuration / 2) {
accountView.transform = .identity accountView.transform = .identity
} }
} }

View File

@ -89,3 +89,11 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController {
root.performSearch(query: query) root.performSearch(query: query)
} }
} }
extension AccountSwitchingContainerViewController: BackgroundableViewController {
func sceneDidEnterBackground() {
if let backgroundable = root as? BackgroundableViewController {
backgroundable.sceneDidEnterBackground()
}
}
}

View File

@ -9,7 +9,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
class NotificationsTableViewController: TimelineLikeTableViewController<NotificationGroup> { class NotificationsTableViewController: DiffableTimelineLikeTableViewController<NotificationsTableViewController.Section, NotificationGroup> {
private let statusCell = "statusCell" private let statusCell = "statusCell"
private let actionGroupCell = "actionGroupCell" private let actionGroupCell = "actionGroupCell"
@ -54,88 +54,9 @@ class NotificationsTableViewController: TimelineLikeTableViewController<Notifica
tableView.register(UINib(nibName: "BasicTableViewCell", bundle: .main), forCellReuseIdentifier: unknownCell) tableView.register(UINib(nibName: "BasicTableViewCell", bundle: .main), forCellReuseIdentifier: unknownCell)
} }
override func loadInitialItems(completion: @escaping ([NotificationGroup]) -> Void) { // MARK: - DiffableTimelineLikeTableViewController
let request = Client.getNotifications(excludeTypes: excludedTypes)
mastodonController.run(request) { (response) in
guard case let .success(notifications, pagination) = response else {
completion([])
return
}
let groups = NotificationGroup.createGroups(notifications: notifications, only: self.groupTypes)
self.newer = pagination?.newer
self.older = pagination?.older
self.mastodonController.persistentContainer.addAll(notifications: notifications) {
completion(groups)
}
}
}
override func loadOlder(completion: @escaping ([NotificationGroup]) -> Void) {
guard let older = older else {
completion([])
return
}
let request = Client.getNotifications(excludeTypes: excludedTypes, range: older)
mastodonController.run(request) { (response) in
guard case let .success(newNotifications, pagination) = response else { fatalError() }
self.older = pagination?.older
let groups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
completion(groups)
}
}
}
override func loadNewer(completion: @escaping ([NotificationGroup]) -> Void) {
guard let newer = newer else {
completion([])
return
}
let request = Client.getNotifications(excludeTypes: excludedTypes, range: newer)
mastodonController.run(request) { (response) in
guard case let .success(newNotifications, pagination) = response else { fatalError() }
if let newer = pagination?.newer {
self.newer = newer
}
let groups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
completion(groups)
}
}
}
private func dismissNotificationsInGroup(at indexPath: IndexPath, completion: (() -> Void)? = nil) {
let group = DispatchGroup()
item(for: indexPath).notifications
.map { Pachyderm.Notification.dismiss(id: $0.id) }
.forEach { (request) in
group.enter()
mastodonController.run(request) { (_) in
group.leave()
}
}
group.notify(queue: .main) {
self.sections[indexPath.section].remove(at: indexPath.row)
self.tableView.deleteRows(at: [indexPath], with: .automatic)
completion?()
}
}
// MARK: - UITableViewDataSource
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let group = item(for: indexPath)
override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ group: NotificationGroup) -> UITableViewCell? {
switch group.kind { switch group.kind {
case .mention: case .mention:
guard let notification = group.notifications.first, guard let notification = group.notifications.first,
@ -179,6 +100,112 @@ class NotificationsTableViewController: TimelineLikeTableViewController<Notifica
} }
} }
override func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
let request = Client.getNotifications(excludeTypes: excludedTypes)
mastodonController.run(request) { (response) in
switch response {
case let .failure(error):
completion(.failure(.client(error)))
case let .success(notifications, pagination):
let groups = NotificationGroup.createGroups(notifications: notifications, only: self.groupTypes)
self.newer = pagination?.newer
self.older = pagination?.older
self.mastodonController.persistentContainer.addAll(notifications: notifications) {
var snapshot = Snapshot()
snapshot.appendSections([.notifications])
snapshot.appendItems(groups, toSection: .notifications)
completion(.success(snapshot))
}
}
}
}
override func loadOlderItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
guard let older = older else {
completion(.failure(.noOlder))
return
}
let request = Client.getNotifications(excludeTypes: excludedTypes, range: older)
mastodonController.run(request) { (response) in
switch response {
case let .failure(error):
completion(.failure(.client(error)))
case let .success(newNotifications, pagination):
if let older = pagination?.older {
self.older = older
}
let groups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
var snapshot = currentSnapshot()
snapshot.appendItems(groups, toSection: .notifications)
completion(.success(snapshot))
}
}
}
}
override func loadNewerItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
guard let newer = newer else {
completion(.failure(.noNewer))
return
}
let request = Client.getNotifications(excludeTypes: excludedTypes, range: newer)
mastodonController.run(request) { (response) in
switch response {
case let .failure(error):
completion(.failure(.client(error)))
case let .success(newNotifications, pagination):
guard !newNotifications.isEmpty else {
completion(.failure(.allCaughtUp))
return
}
if let newer = pagination?.newer {
self.newer = newer
}
let groups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
var snapshot = currentSnapshot()
if let first = snapshot.itemIdentifiers(inSection: .notifications).first {
snapshot.insertItems(groups, beforeItem: first)
} else {
snapshot.appendItems(groups, toSection: .notifications)
}
completion(.success(snapshot))
}
}
}
}
private func dismissNotificationsInGroup(at indexPath: IndexPath, completion: (() -> Void)? = nil) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
let group = DispatchGroup()
item.notifications
.map { Pachyderm.Notification.dismiss(id: $0.id) }
.forEach { (request) in
group.enter()
mastodonController.run(request) { (_) in
group.leave()
}
}
group.notify(queue: .main) {
var snapshot = self.dataSource.snapshot()
snapshot.deleteItems([item])
self.dataSource.apply(snapshot, completion: completion)
}
}
// MARK: - UITableViewDelegate // MARK: - UITableViewDelegate
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
@ -211,6 +238,12 @@ class NotificationsTableViewController: TimelineLikeTableViewController<Notifica
} }
} }
extension NotificationsTableViewController {
enum Section: CaseIterable, Hashable {
case notifications
}
}
extension NotificationsTableViewController: TuskerNavigationDelegate { extension NotificationsTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController { mastodonController }
} }
@ -224,7 +257,8 @@ extension NotificationsTableViewController: StatusTableViewCellDelegate {
extension NotificationsTableViewController: UITableViewDataSourcePrefetching { extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { for indexPath in indexPaths {
for notification in item(for: indexPath).notifications { guard let group = dataSource.itemIdentifier(for: indexPath) else { continue }
for notification in group.notifications {
ImageCache.avatars.fetchIfNotCached(notification.account.avatar) ImageCache.avatars.fetchIfNotCached(notification.account.avatar)
} }
} }
@ -232,7 +266,8 @@ extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { for indexPath in indexPaths {
for notification in item(for: indexPath).notifications { guard let group = dataSource.itemIdentifier(for: indexPath) else { continue }
for notification in group.notifications {
ImageCache.avatars.cancelWithoutCallback(notification.account.avatar) ImageCache.avatars.cancelWithoutCallback(notification.account.avatar)
} }
} }

View File

@ -29,6 +29,8 @@ class InstanceSelectorTableViewController: UITableViewController {
var urlHandler: AnyCancellable? var urlHandler: AnyCancellable?
var currentQuery: String? var currentQuery: String?
private var activityIndicator: UIActivityIndicatorView!
override var supportedInterfaceOrientations: UIInterfaceOrientationMask { override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
if UIDevice.current.userInterfaceIdiom == .phone { if UIDevice.current.userInterfaceIdiom == .phone {
return .portrait return .portrait
@ -50,10 +52,15 @@ class InstanceSelectorTableViewController: UITableViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
tableView.register(UINib(nibName: "InstanceTableViewCell", bundle: .main), forCellReuseIdentifier: instanceCell) // disable transparent background when scrolled to top because it gets weird with animating table items in and out
let appearance = UINavigationBarAppearance()
appearance.configureWithDefaultBackground()
navigationItem.scrollEdgeAppearance = appearance
tableView.register(UINib(nibName: "InstanceTableViewCell", bundle: .main), forCellReuseIdentifier: instanceCell)
tableView.rowHeight = UITableView.automaticDimension tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120 tableView.estimatedRowHeight = 120
createActivityIndicatorHeader()
dataSource = DataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in dataSource = DataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
switch item { switch item {
@ -73,12 +80,19 @@ class InstanceSelectorTableViewController: UITableViewController {
searchController.obscuresBackgroundDuringPresentation = false searchController.obscuresBackgroundDuringPresentation = false
searchController.searchBar.searchTextField.autocapitalizationType = .none searchController.searchBar.searchTextField.autocapitalizationType = .none
navigationItem.searchController = searchController navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false
definesPresentationContext = true definesPresentationContext = true
urlHandler = urlCheckerSubject urlHandler = urlCheckerSubject
.debounce(for: .seconds(1), scheduler: RunLoop.main)
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.sink(receiveValue: updateSpecificInstance) .map { [weak self] (s) -> String in
if !s.isEmpty {
self?.activityIndicator.startAnimating()
}
return s
}
.debounce(for: .seconds(1), scheduler: RunLoop.main)
.sink { [weak self] in self?.updateSpecificInstance(domain: $0) }
loadRecommendedInstances() loadRecommendedInstances()
} }
@ -112,6 +126,8 @@ class InstanceSelectorTableViewController: UITableViewController {
} }
private func updateSpecificInstance(domain: String) { private func updateSpecificInstance(domain: String) {
activityIndicator.startAnimating()
let components = parseURLComponents(input: domain) let components = parseURLComponents(input: domain)
let url = components.url! let url = components.url!
@ -120,16 +136,26 @@ class InstanceSelectorTableViewController: UITableViewController {
client.run(request) { (response) in client.run(request) { (response) in
var snapshot = self.dataSource.snapshot() var snapshot = self.dataSource.snapshot()
if snapshot.indexOfSection(.selected) != nil { if snapshot.indexOfSection(.selected) != nil {
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .selected)) snapshot.deleteSections([.selected])
} }
if case let .success(instance, _) = response { if case let .success(instance, _) = response {
if !snapshot.sectionIdentifiers.contains(.selected) { if snapshot.indexOfSection(.recommendedInstances) != nil {
snapshot.insertSections([.selected], beforeSection: .recommendedInstances)
} else {
snapshot.appendSections([.selected]) snapshot.appendSections([.selected])
} }
snapshot.appendItems([.selected(url, instance)], toSection: .selected) snapshot.appendItems([.selected(url, instance)], toSection: .selected)
DispatchQueue.main.async { DispatchQueue.main.async {
self.dataSource.apply(snapshot) self.dataSource.apply(snapshot) {
self.activityIndicator.stopAnimating()
}
}
} else {
DispatchQueue.main.async {
self.activityIndicator.stopAnimating()
} }
} }
} }
@ -137,14 +163,69 @@ class InstanceSelectorTableViewController: UITableViewController {
private func loadRecommendedInstances() { private func loadRecommendedInstances() {
InstanceSelector.getInstances(category: nil) { (response) in InstanceSelector.getInstances(category: nil) { (response) in
guard case let .success(instances, _) = response else { fatalError() } DispatchQueue.main.async {
switch response {
case let .failure(error):
self.showRecommendationsError(error)
case let .success(instances, _):
self.recommendedInstances = instances self.recommendedInstances = instances
self.filterRecommendedResults() self.filterRecommendedResults()
} }
} }
}
}
func filterRecommendedResults() { private func createActivityIndicatorHeader() {
let header = UITableViewHeaderFooterView()
header.translatesAutoresizingMaskIntoConstraints = false
header.contentView.backgroundColor = .secondarySystemBackground
activityIndicator = UIActivityIndicatorView(style: .large)
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
header.contentView.addSubview(activityIndicator)
NSLayoutConstraint.activate([
activityIndicator.centerXAnchor.constraint(equalTo: header.contentView.centerXAnchor),
activityIndicator.topAnchor.constraint(equalTo: header.contentView.topAnchor, constant: 4),
activityIndicator.bottomAnchor.constraint(equalTo: header.contentView.bottomAnchor, constant: -4),
])
let fittingSize = CGSize(width: tableView.bounds.width - (tableView.safeAreaInsets.left + tableView.safeAreaInsets.right), height: 0)
let size = header.systemLayoutSizeFitting(fittingSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
header.frame = CGRect(origin: .zero, size: size)
tableView.tableHeaderView = header
}
private func showRecommendationsError(_ error: Client.Error) {
let footer = UITableViewHeaderFooterView()
footer.translatesAutoresizingMaskIntoConstraints = false
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .secondaryLabel
label.textAlignment = .center
label.font = .boldSystemFont(ofSize: 17)
label.numberOfLines = 0
label.text = "Could not fetch suggested instances: \(error.localizedDescription)"
footer.contentView.addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalToSystemSpacingAfter: footer.contentView.leadingAnchor, multiplier: 1),
footer.contentView.trailingAnchor.constraint(equalToSystemSpacingAfter: label.trailingAnchor, multiplier: 1),
label.topAnchor.constraint(equalTo: footer.contentView.topAnchor, constant: 8),
label.bottomAnchor.constraint(equalTo: footer.contentView.bottomAnchor, constant: 8),
])
let fittingSize = CGSize(width: tableView.bounds.width - (tableView.safeAreaInsets.left + tableView.safeAreaInsets.right), height: 0)
let size = footer.systemLayoutSizeFitting(fittingSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
footer.frame = CGRect(origin: .zero, size: size)
tableView.tableFooterView = footer
}
private func filterRecommendedResults() {
let filteredInstances: [InstanceSelector.Instance] let filteredInstances: [InstanceSelector.Instance]
if let currentQuery = currentQuery, !currentQuery.isEmpty { if let currentQuery = currentQuery, !currentQuery.isEmpty {
filteredInstances = recommendedInstances.filter { filteredInstances = recommendedInstances.filter {
@ -155,13 +236,21 @@ class InstanceSelectorTableViewController: UITableViewController {
} }
var snapshot = self.dataSource.snapshot() var snapshot = self.dataSource.snapshot()
snapshot.deleteSections([.recommendedInstances]) if snapshot.indexOfSection(.recommendedInstances) != nil {
snapshot.appendSections([.recommendedInstances]) let toRemove = snapshot.itemIdentifiers(inSection: .recommendedInstances).filter {
snapshot.appendItems(filteredInstances.map { Item.recommended($0) }, toSection: .recommendedInstances) if case .recommended(_) = $0 {
DispatchQueue.main.async { return true
self.dataSource.apply(snapshot) } else {
return false
} }
} }
snapshot.deleteItems(toRemove)
} else {
snapshot.appendSections([.recommendedInstances])
}
snapshot.appendItems(filteredInstances.map { Item.recommended($0) }, toSection: .recommendedInstances)
self.dataSource.apply(snapshot)
}
// MARK: - Table view delegate // MARK: - Table view delegate
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
@ -194,29 +283,30 @@ extension InstanceSelectorTableViewController {
case recommended(InstanceSelector.Instance) case recommended(InstanceSelector.Instance)
static func ==(lhs: Item, rhs: Item) -> Bool { static func ==(lhs: Item, rhs: Item) -> Bool {
if case let .selected(url, instance) = lhs, switch (lhs, rhs) {
case let .selected(otherUrl, other) = rhs { case let (.selected(urlA, instanceA), .selected(urlB, instanceB)):
return url == otherUrl && instance.uri == other.uri return urlA == urlB && instanceA.uri == instanceB.uri
} else if case let .recommended(instance) = lhs, case let (.recommended(a), .recommended(b)):
case let .recommended(other) = rhs { return a.domain == b.domain
return instance.domain == other.domain default:
}
return false return false
} }
}
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
switch self { switch self {
case let .selected(url, instance): case let .selected(url, instance):
hasher.combine(Section.selected) hasher.combine(0)
hasher.combine(url) hasher.combine(url)
hasher.combine(instance.uri) hasher.combine(instance.uri)
case let .recommended(instance): case let .recommended(instance):
hasher.combine(Section.recommendedInstances) hasher.combine(1)
hasher.combine(instance.domain) hasher.combine(instance.domain)
} }
} }
} }
class DataSource: UITableViewDiffableDataSource<Section, Item> { class DataSource: UITableViewDiffableDataSource<Section, Item> {
} }
} }

View File

@ -70,8 +70,8 @@ class ProfileViewController: UIPageViewController {
let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning)) let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning))
composeButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: [ composeButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: [
UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] (_) in
self.composeDirectMentioning() self?.composeDirectMentioning()
}) })
]) ])
composeButton.isEnabled = mastodonController.loggedIn composeButton.isEnabled = mastodonController.loggedIn

View File

@ -20,6 +20,7 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
private var older: RequestRange? private var older: RequestRange?
private var didConfirmLoadMore = false private var didConfirmLoadMore = false
private var isShowingTimelineDescription = false
init(for timeline: Timeline, mastodonController: MastodonController) { init(for timeline: Timeline, mastodonController: MastodonController) {
self.timeline = timeline self.timeline = timeline
@ -58,6 +59,42 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: "statusCell") tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: "statusCell")
tableView.register(UINib(nibName: "ConfirmLoadMoreTableViewCell", bundle: .main), forCellReuseIdentifier: "confirmLoadMoreCell") tableView.register(UINib(nibName: "ConfirmLoadMoreTableViewCell", bundle: .main), forCellReuseIdentifier: "confirmLoadMoreCell")
tableView.register(UINib(nibName: "PublicTimelineDescriptionTableViewCell", bundle: .main), forCellReuseIdentifier: "publicTimelineDescriptionCell")
if case let .public(local: local) = timeline,
(local && !Preferences.shared.hasShownLocalTimelineDescription) || (!local && !Preferences.shared.hasShownFederatedTimelineDescription) {
isShowingTimelineDescription = true
var snapshot = self.dataSource.snapshot()
snapshot.appendSections([.header])
snapshot.appendItems([.publicTimelineDescription(local: local)], toSection: .header)
self.dataSource.apply(snapshot, animatingDifferences: false)
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if case let .public(local: local) = timeline {
if local {
Preferences.shared.hasShownLocalTimelineDescription = true
} else {
Preferences.shared.hasShownFederatedTimelineDescription = true
}
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isShowingTimelineDescription {
isShowingTimelineDescription = false
var snapshot = self.dataSource.snapshot()
snapshot.deleteSections([.header])
self.dataSource.apply(snapshot, animatingDifferences: false)
}
} }
// MARK: - DiffableTimelineLikeTableViewController // MARK: - DiffableTimelineLikeTableViewController
@ -87,6 +124,17 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
self.didConfirmLoadMore = false self.didConfirmLoadMore = false
} }
return cell return cell
case .publicTimelineDescription(local: let local):
let cell = tableView.dequeueReusableCell(withIdentifier: "publicTimelineDescriptionCell", for: indexPath) as! PublicTimelineDescriptionTableViewCell
cell.mastodonController = mastodonController
cell.local = local
cell.didDismiss = { [unowned self] in
var snapshot = self.dataSource.snapshot()
snapshot.deleteSections([.header])
self.dataSource.apply(snapshot)
}
return cell
} }
} }
@ -108,7 +156,9 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
self.older = pagination?.older self.older = pagination?.older
self.mastodonController.persistentContainer.addAll(statuses: statuses) { self.mastodonController.persistentContainer.addAll(statuses: statuses) {
var snapshot = Snapshot() DispatchQueue.main.async {
var snapshot = self.dataSource.snapshot()
snapshot.deleteSections([.statuses, .footer])
snapshot.appendSections([.statuses, .footer]) snapshot.appendSections([.statuses, .footer])
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown) }, toSection: .statuses) snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown) }, toSection: .statuses)
completion(.success(snapshot)) completion(.success(snapshot))
@ -116,8 +166,9 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
} }
} }
} }
}
override func loadOlderItems(currentSnapshot: Snapshot, completion: @escaping (LoadResult) -> Void) { override func loadOlderItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
guard let older = older else { guard let older = older else {
completion(.failure(.noOlder)) completion(.failure(.noOlder))
return return
@ -125,12 +176,12 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
if #available(iOS 15.0, *), if #available(iOS 15.0, *),
Preferences.shared.disableInfiniteScrolling && !didConfirmLoadMore { Preferences.shared.disableInfiniteScrolling && !didConfirmLoadMore {
guard !currentSnapshot.itemIdentifiers(inSection: .footer).contains(.confirmLoadMore) else { var snapshot = currentSnapshot()
guard !snapshot.itemIdentifiers(inSection: .footer).contains(.confirmLoadMore) else {
// todo: need something more accurate than "success"/"failure" // todo: need something more accurate than "success"/"failure"
completion(.success(currentSnapshot)) completion(.success(snapshot))
return return
} }
var snapshot = currentSnapshot
snapshot.appendItems([.confirmLoadMore], toSection: .footer) snapshot.appendItems([.confirmLoadMore], toSection: .footer)
self.dataSource.apply(snapshot) self.dataSource.apply(snapshot)
completion(.success(snapshot)) completion(.success(snapshot))
@ -148,7 +199,7 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
self.older = pagination?.older self.older = pagination?.older
self.mastodonController.persistentContainer.addAll(statuses: statuses) { self.mastodonController.persistentContainer.addAll(statuses: statuses) {
var snapshot = currentSnapshot var snapshot = currentSnapshot()
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown) }, toSection: .statuses) snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown) }, toSection: .statuses)
snapshot.deleteItems([.confirmLoadMore]) snapshot.deleteItems([.confirmLoadMore])
completion(.success(snapshot)) completion(.success(snapshot))
@ -157,7 +208,7 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
} }
} }
override func loadNewerItems(currentSnapshot: Snapshot, completion: @escaping (LoadResult) -> Void) { override func loadNewerItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
guard let newer = newer else { guard let newer = newer else {
completion(.failure(.noNewer)) completion(.failure(.noNewer))
return return
@ -170,6 +221,11 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
completion(.failure(.client(error))) completion(.failure(.client(error)))
case let .success(statuses, pagination): case let .success(statuses, pagination):
guard !statuses.isEmpty else {
completion(.failure(.allCaughtUp))
return
}
// if there are no new statuses, pagination is nil // if there are no new statuses, pagination is nil
// if we were to then overwrite self.newer, future refresh would fail // if we were to then overwrite self.newer, future refresh would fail
if let newer = pagination?.newer { if let newer = pagination?.newer {
@ -177,7 +233,7 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
} }
self.mastodonController.persistentContainer.addAll(statuses: statuses) { self.mastodonController.persistentContainer.addAll(statuses: statuses) {
var snapshot = currentSnapshot var snapshot = currentSnapshot()
let newIdentifiers = statuses.map { Item.status(id: $0.id, state: .unknown) } let newIdentifiers = statuses.map { Item.status(id: $0.id, state: .unknown) }
if let first = snapshot.itemIdentifiers(inSection: .statuses).first { if let first = snapshot.itemIdentifiers(inSection: .statuses).first {
snapshot.insertItems(newIdentifiers, beforeItem: first) snapshot.insertItems(newIdentifiers, beforeItem: first)
@ -196,16 +252,28 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
} }
} }
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
super.scrollViewWillBeginDragging(scrollView)
if isShowingTimelineDescription {
var snapshot = self.dataSource.snapshot()
snapshot.deleteSections([.header])
self.dataSource.apply(snapshot, animatingDifferences: true)
}
}
} }
extension TimelineTableViewController { extension TimelineTableViewController {
enum Section: Hashable, CaseIterable { enum Section: Hashable, CaseIterable {
case header
case statuses case statuses
case footer case footer
} }
enum Item: Hashable { enum Item: Hashable {
case status(id: String, state: StatusState) case status(id: String, state: StatusState)
case confirmLoadMore case confirmLoadMore
case publicTimelineDescription(local: Bool)
static func ==(lhs: Item, rhs: Item) -> Bool { static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
@ -213,6 +281,8 @@ extension TimelineTableViewController {
return a == b return a == b
case (.confirmLoadMore, .confirmLoadMore): case (.confirmLoadMore, .confirmLoadMore):
return true return true
case let (.publicTimelineDescription(local: a), .publicTimelineDescription(local: b)):
return a == b
default: default:
return false return false
} }
@ -225,6 +295,9 @@ extension TimelineTableViewController {
hasher.combine(id) hasher.combine(id)
case .confirmLoadMore: case .confirmLoadMore:
hasher.combine(1) hasher.combine(1)
case let .publicTimelineDescription(local: local):
hasher.combine(2)
hasher.combine(local)
} }
} }
} }

View File

@ -59,6 +59,7 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
super.viewDidDisappear(animated) super.viewDidDisappear(animated)
pruneOffscreenRows() pruneOffscreenRows()
currentToast?.dismissToast(animated: false)
} }
class func refreshCommandTitle() -> String { class func refreshCommandTitle() -> String {
@ -111,13 +112,27 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
state = .loadingInitial state = .loadingInitial
loadInitialItems() { result in loadInitialItems() { result in
guard case let .success(snapshot) = result else {
self.state = .unloaded
return
}
DispatchQueue.main.async { DispatchQueue.main.async {
switch result {
case let .success(snapshot):
self.dataSource.apply(snapshot, animatingDifferences: false) self.dataSource.apply(snapshot, animatingDifferences: false)
self.state = .loaded self.state = .loaded
case let .failure(.client(error)):
self.state = .unloaded
var config = ToastConfiguration(title: "Error Loading")
config.subtitle = error.localizedDescription
config.systemImageName = error.systemImageName
config.actionTitle = "Retry"
config.action = { [weak self] (toast) in
toast.dismissToast(animated: true)
self?.loadInitial()
}
self.showToast(configuration: config, animated: true)
default:
self.state = .unloaded
}
} }
} }
} }
@ -132,14 +147,28 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
state = .loadingOlder state = .loadingOlder
loadOlderItems(currentSnapshot: dataSource.snapshot()) { result in loadOlderItems(currentSnapshot: dataSource.snapshot) { result in
guard case let .success(snapshot) = result else {
self.state = .loaded
return
}
DispatchQueue.main.async { DispatchQueue.main.async {
self.dataSource.apply(snapshot, animatingDifferences: false)
self.state = .loaded self.state = .loaded
switch result {
case let .success(snapshot):
self.dataSource.apply(snapshot, animatingDifferences: false)
case let .failure(.client(error)):
var config = ToastConfiguration(title: "Error Loading Older")
config.subtitle = error.localizedDescription
config.systemImageName = error.systemImageName
config.actionTitle = "Retry"
config.action = { [weak self] (toast) in
toast.dismissToast(animated: true)
self?.loadOlder()
}
self.showToast(configuration: config, animated: true)
default:
break
}
} }
} }
} }
@ -156,8 +185,9 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
// this assumes that indexPathsForVisibleRows is always in order // this assumes that indexPathsForVisibleRows is always in order
lastLastVisibleRow = tableView.indexPathsForVisibleRows?.last lastLastVisibleRow = tableView.indexPathsForVisibleRows?.last
let orderedContentSections = dataSource.snapshot().sectionIdentifiers.filter { timelineContentSections().contains($0) } let orderedContentSections = dataSource.snapshot().sectionIdentifiers.enumerated().filter { timelineContentSections().contains($0.element) }
if indexPath.section == orderedContentSections.count - 1, if let lastContentSection = orderedContentSections.last,
indexPath.section == lastContentSection.offset,
indexPath.row == tableView.numberOfRows(inSection: indexPath.section) - 1 { indexPath.row == tableView.numberOfRows(inSection: indexPath.section) - 1 {
loadOlder() loadOlder()
@ -183,35 +213,58 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
state = .loadingNewer state = .loadingNewer
let snapshot = dataSource.snapshot() var firstItem: Item? = nil
let currentSnapshot: () -> Snapshot = {
let snapshot = self.dataSource.snapshot()
var item: Item? = nil for section in self.timelineContentSections() {
for section in timelineContentSections() { if snapshot.indexOfSection(section) != nil,
if let first = snapshot.itemIdentifiers(inSection: section).first { let first = snapshot.itemIdentifiers(inSection: section).first {
item = first firstItem = first
break break
} }
} }
loadNewerItems(currentSnapshot: snapshot) { result in return snapshot
guard case let .success(snapshot) = result else { }
loadNewerItems(currentSnapshot: currentSnapshot) { result in
DispatchQueue.main.async { DispatchQueue.main.async {
self.refreshControl?.endRefreshing() self.refreshControl?.endRefreshing()
self.state = .loaded self.state = .loaded
}
return
}
DispatchQueue.main.async { switch result {
self.refreshControl?.endRefreshing() case let .success(snapshot):
self.dataSource.apply(snapshot, animatingDifferences: false) self.dataSource.apply(snapshot, animatingDifferences: false)
self.state = .loaded if let firstItem = firstItem,
let indexPath = self.dataSource.indexPath(for: firstItem) {
if let item = item,
let indexPath = self.dataSource.indexPath(for: item) {
// maintain the current position in the list (don't scroll to top) // maintain the current position in the list (don't scroll to top)
self.tableView.scrollToRow(at: indexPath, at: .top, animated: false) self.tableView.scrollToRow(at: indexPath, at: .top, animated: false)
} }
case let .failure(.client(error)):
var config = ToastConfiguration(title: "Error Loading Newer")
config.subtitle = error.localizedDescription
config.systemImageName = error.systemImageName
config.actionTitle = "Retry"
config.action = { [weak self] (toast) in
toast.dismissToast(animated: true)
self?.refresh()
}
self.showToast(configuration: config, animated: true)
case .failure(.allCaughtUp):
var config = ToastConfiguration(title: "You're all caught up")
config.edge = .top
config.dismissAutomaticallyAfter = 2
config.action = { (toast) in
toast.dismissToast(animated: true)
}
self.showToast(configuration: config, animated: true)
default:
break
}
} }
} }
} }
@ -226,11 +279,11 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
fatalError("loadInitialItems(completion:) must be implemented by subclasses") fatalError("loadInitialItems(completion:) must be implemented by subclasses")
} }
func loadOlderItems(currentSnapshot: Snapshot, completion: @escaping (LoadResult) -> Void) { func loadOlderItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
fatalError("loadOlderItesm(completion:) must be implemented by subclasses") fatalError("loadOlderItesm(completion:) must be implemented by subclasses")
} }
func loadNewerItems(currentSnapshot: Snapshot, completion: @escaping (LoadResult) -> Void) { func loadNewerItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
fatalError("loadNewerItems(completion:) must be implemented by subclasses") fatalError("loadNewerItems(completion:) must be implemented by subclasses")
} }
@ -258,6 +311,7 @@ extension DiffableTimelineLikeTableViewController {
case noClient case noClient
case noOlder case noOlder
case noNewer case noNewer
case allCaughtUp
case client(Client.Error) case client(Client.Error)
} }
} }
@ -265,5 +319,20 @@ extension DiffableTimelineLikeTableViewController {
extension DiffableTimelineLikeTableViewController: BackgroundableViewController { extension DiffableTimelineLikeTableViewController: BackgroundableViewController {
func sceneDidEnterBackground() { func sceneDidEnterBackground() {
pruneOffscreenRows() pruneOffscreenRows()
currentToast?.dismissToast(animated: false)
}
}
extension DiffableTimelineLikeTableViewController: ToastableViewController {
}
fileprivate extension Client.Error {
var systemImageName: String {
switch self {
case .networkError(_):
return "wifi.exclamationmark"
default:
return "exclamationmark.triangle"
}
} }
} }

View File

@ -67,7 +67,9 @@ extension MenuPreviewProvider {
guard let self = self, guard let self = self,
case let .success(results, _) = response, case let .success(results, _) = response,
let relationship = results.first else { let relationship = results.first else {
DispatchQueue.main.async {
elementHandler([]) elementHandler([])
}
return return
} }
let following = relationship.following let following = relationship.following

View File

@ -143,9 +143,9 @@ class ContentTextView: LinkTextView {
case "del": case "del":
attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: attributed.fullRange) attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: attributed.fullRange)
case "code": case "code":
attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: self.font!.pointSize, weight: .regular), range: attributed.fullRange) attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: defaultFont.pointSize, weight: .regular), range: attributed.fullRange)
case "pre": case "pre":
attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: self.font!.pointSize, weight: .regular), range: attributed.fullRange) attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: defaultFont.pointSize, weight: .regular), range: attributed.fullRange)
attributed.append(NSAttributedString(string: "\n\n")) attributed.append(NSAttributedString(string: "\n\n"))
case "ol", "ul": case "ol", "ul":
attributed.trimLeadingCharactersInSet(.whitespacesAndNewlines) attributed.trimLeadingCharactersInSet(.whitespacesAndNewlines)
@ -157,7 +157,7 @@ class ContentTextView: LinkTextView {
if parentTag == "ol" { if parentTag == "ol" {
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
bullet = NSAttributedString(string: "\(index + 1).\t", attributes: [.font: UIFont.monospacedDigitSystemFont(ofSize: self.font!.pointSize, weight: .regular)]) bullet = NSAttributedString(string: "\(index + 1).\t", attributes: [.font: UIFont.monospacedDigitSystemFont(ofSize: defaultFont.pointSize, weight: .regular)])
} else if parentTag == "ul" { } else if parentTag == "ul" {
bullet = NSAttributedString(string: "\u{2022}\t") bullet = NSAttributedString(string: "\u{2022}\t")
} else { } else {

View File

@ -0,0 +1,57 @@
//
// PublicTimelineDescriptionTableViewCell.swift
// PublicTimelineDescriptionTableViewCell
//
// Created by Shadowfacts on 8/7/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
class PublicTimelineDescriptionTableViewCell: UITableViewCell {
weak var mastodonController: MastodonController!
var local = false {
didSet {
updateLabel()
}
}
var didDismiss: (() -> Void)?
@IBOutlet private weak var descriptionLabel: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
if #available(iOS 15.0, *) {
contentView.backgroundColor = .tintColor
} else {
contentView.backgroundColor = .systemBlue
}
}
private func updateLabel() {
let str = NSMutableAttributedString()
let instanceStr = NSAttributedString(string: mastodonController.instanceURL.host!, attributes: [
.font: UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0)
])
if local {
str.append(NSAttributedString(string: "The local timeline shows public posts from only "))
str.append(instanceStr)
str.append(NSAttributedString(string: "."))
} else {
str.append(NSAttributedString(string: "The federated timeline shows public posts from all users that "))
str.append(instanceStr)
str.append(NSAttributedString(string: " knows about."))
}
descriptionLabel.attributedText = str
}
}
extension PublicTimelineDescriptionTableViewCell: SelectableTableViewCell {
func didSelectCell() {
didDismiss?()
}
}

View File

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19150" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19134"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" rowHeight="78" id="KGk-i7-Jjw" customClass="PublicTimelineDescriptionTableViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="320" height="78"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
<rect key="frame" x="0.0" y="0.0" width="320" height="78"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="N97-CH-58I">
<rect key="frame" x="16" y="8" width="288" height="62"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" systemColor="systemBlueColor"/>
<constraints>
<constraint firstAttribute="bottom" secondItem="N97-CH-58I" secondAttribute="bottom" constant="8" id="2Lg-we-j2c"/>
<constraint firstItem="N97-CH-58I" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" constant="8" id="FdS-q9-obT"/>
<constraint firstItem="N97-CH-58I" firstAttribute="trailing" secondItem="H2p-sc-9uM" secondAttribute="trailingMargin" id="KqX-Qy-18G"/>
<constraint firstItem="N97-CH-58I" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leadingMargin" id="Pqy-8N-OnX"/>
</constraints>
</tableViewCellContentView>
<viewLayoutGuide key="safeArea" id="njF-e1-oar"/>
<connections>
<outlet property="descriptionLabel" destination="N97-CH-58I" id="z1W-HD-xy9"/>
</connections>
<point key="canvasLocation" x="131.8840579710145" y="121.875"/>
</tableViewCell>
</objects>
<resources>
<systemColor name="systemBlueColor">
<color red="0.0" green="0.47843137254901963" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View File

@ -0,0 +1,33 @@
//
// ToastConfiguration.swift
// ToastConfiguration
//
// Created by Shadowfacts on 8/14/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
struct ToastConfiguration {
var systemImageName: String?
var titleFont: UIFont = .boldSystemFont(ofSize: 14)
var title: String
var subtitle: String?
var actionTitle: String?
var action: ((ToastView) -> Void)?
var edgeSpacing: CGFloat = 8
var edge: Edge = .automatic
var dismissOnScroll = true
var dismissAutomaticallyAfter: TimeInterval? = nil
init(title: String) {
self.title = title
}
enum Edge: Equatable {
case top
case bottom
/// Determines edge based on the current device. Bottom on iPhone, top on iPad/Mac.
case automatic
}
}

View File

@ -0,0 +1,255 @@
//
// ToastView.swift
// ToastView
//
// Created by Shadowfacts on 8/14/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
class ToastView: UIView {
let configuration: ToastConfiguration
private var shrinkAnimator: UIViewPropertyAnimator?
private var recognizedGesture = false
private var shouldDismissOnScroll = false
private(set) var shouldDismissAutomatically = true
private var offscreenTranslation: CGFloat {
var translation = bounds.height + configuration.edgeSpacing
if configuration.edge == .bottom {
translation += superview?.safeAreaInsets.bottom ?? 0
} else {
translation += superview?.safeAreaInsets.top ?? 0
translation *= -1
}
return translation
}
init(configuration: ToastConfiguration) {
precondition(configuration.edge != .automatic)
self.configuration = configuration
super.init(frame: .zero)
setupView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupView() {
backgroundColor = .systemBlue
layer.shadowColor = UIColor.black.cgColor
layer.shadowRadius = 5
layer.shadowOffset = CGSize(width: 0, height: 2.5)
layer.shadowOpacity = 0.5
layer.masksToBounds = false
let stack = UIStackView()
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .horizontal
stack.spacing = 8
if let name = configuration.systemImageName {
let imageView = UIImageView(image: UIImage(systemName: name))
imageView.tintColor = .white
imageView.contentMode = .scaleAspectFit
stack.addArrangedSubview(imageView)
}
let titleLabel = UILabel()
titleLabel.text = configuration.title
titleLabel.textColor = .white
titleLabel.font = configuration.titleFont
titleLabel.adjustsFontSizeToFitWidth = true
if let subtitle = configuration.subtitle {
let subtitleLabel = UILabel()
subtitleLabel.text = subtitle
subtitleLabel.textColor = .white
subtitleLabel.numberOfLines = 0
subtitleLabel.font = .systemFont(ofSize: 14)
let vStack = UIStackView(arrangedSubviews: [
titleLabel,
subtitleLabel
])
vStack.axis = .vertical
vStack.spacing = 4
stack.addArrangedSubview(vStack)
} else {
stack.addArrangedSubview(titleLabel)
}
if let actionTitle = configuration.actionTitle {
let actionLabel = UILabel()
actionLabel.text = actionTitle
actionLabel.font = .boldSystemFont(ofSize: 16)
actionLabel.textColor = .white
stack.addArrangedSubview(actionLabel)
}
addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalToSystemSpacingAfter: leadingAnchor, multiplier: 1),
trailingAnchor.constraint(equalToSystemSpacingAfter: stack.trailingAnchor, multiplier: 1),
stack.topAnchor.constraint(equalTo: topAnchor, constant: 4),
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
])
let pan = UIPanGestureRecognizer(target: self, action: #selector(panRecognized))
addGestureRecognizer(pan)
}
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = min(32, bounds.height / 2)
layer.shadowPath = CGPath(roundedRect: bounds, cornerWidth: layer.cornerRadius, cornerHeight: layer.cornerRadius, transform: nil)
}
func dismissToast(animated: Bool) {
guard animated else {
removeFromSuperview()
return
}
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) {
self.transform = CGAffineTransform(translationX: 0, y: self.offscreenTranslation)
} completion: { (_) in
self.removeFromSuperview()
}
}
func animateAppearance() {
self.transform = CGAffineTransform(translationX: 0, y: offscreenTranslation)
let duration = 0.5
let velocity = 0.5
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.65, initialSpringVelocity: velocity, options: []) {
self.transform = .identity
}
}
func setupDismissOnScroll(connectedTo scrollView: UIScrollView) {
guard configuration.dismissOnScroll else { return }
scrollView.panGestureRecognizer.addTarget(self, action: #selector(scrollViewPanGestureRecognized))
}
// MARK: - Interaction
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
recognizedGesture = false
shouldDismissAutomatically = false
shrinkAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut) {
self.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
}
shrinkAnimator?.startAnimation(afterDelay: 0.1)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
if !recognizedGesture {
guard let shrinkAnimator = shrinkAnimator else {
return
}
shrinkAnimator.stopAnimation(true)
UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseInOut) {
self.transform = .identity
}
configuration.action?(self)
}
shrinkAnimator = nil
}
@objc private func panRecognized(_ recognizer: UIPanGestureRecognizer) {
let translation = recognizer.translation(in: self).y
let isDraggingAwayFromDismissalEdge = (configuration.edge == .top && translation > 0) || (configuration.edge == .bottom && translation < 0)
switch recognizer.state {
case .began:
recognizedGesture = true
shouldDismissAutomatically = false
UIView.animate(withDuration: 0.1, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) {
self.transform = .identity
}
break
case .changed:
var distance: CGFloat
if isDraggingAwayFromDismissalEdge {
distance = sqrt(abs(translation))
if configuration.edge == .bottom {
distance *= -1
}
} else {
distance = translation
}
transform = CGAffineTransform(translationX: 0, y: distance)
case .ended, .cancelled:
let velocity = recognizer.velocity(in: self).y
let distance = isDraggingAwayFromDismissalEdge ? sqrt(abs(translation)) : translation
let minDismissalDistance = configuration.edgeSpacing + bounds.height / 2
let dismissDueToDistance = configuration.edge == .bottom ? distance > minDismissalDistance : -distance > minDismissalDistance
let minDismissalVelocity: CGFloat = 250
let dismissDueToVelocity = configuration.edge == .bottom ? velocity > minDismissalDistance : velocity < -minDismissalVelocity
if dismissDueToDistance || dismissDueToVelocity {
if abs(translation) < abs(offscreenTranslation) {
let distanceLeft = offscreenTranslation - translation
let duration = 1 / TimeInterval(max(velocity, minDismissalVelocity) / distanceLeft)
UIView.animate(withDuration: duration, delay: 0, options: .allowUserInteraction) {
self.transform = CGAffineTransform(translationX: 0, y: self.offscreenTranslation)
} completion: { (_) in
self.removeFromSuperview()
}
} else {
self.removeFromSuperview()
}
} else {
let duration = 0.5
let velocity = distance * duration
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.65, initialSpringVelocity: velocity, options: .allowUserInteraction) {
self.transform = .identity
}
}
default:
break
}
}
@objc private func scrollViewPanGestureRecognized(_ recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
shouldDismissOnScroll = true
case .changed:
let translation = recognizer.translation(in: recognizer.view).y
if shouldDismissOnScroll && abs(translation) > 50 {
dismissToast(animated: true)
shouldDismissOnScroll = false
}
case .ended, .cancelled:
shouldDismissOnScroll = false
default:
break
}
}
}

View File

@ -0,0 +1,105 @@
//
// ToastableViewController.swift
// ToastableViewController
//
// Created by Shadowfacts on 8/14/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
protocol ToastableViewController: UIViewController {
var toastParentView: UIView { get }
var toastScrollView: UIScrollView? { get }
}
private var currentToastKey = "Tusker_currentToast"
extension ToastableViewController {
private(set) var currentToast: ToastView? {
get {
let holder = objc_getAssociatedObject(self, &currentToastKey) as? WeakHolder<ToastView>
return holder?.object
}
set {
if let newValue = newValue {
objc_setAssociatedObject(self, &currentToastKey, WeakHolder(object: newValue), .OBJC_ASSOCIATION_RETAIN)
} else {
objc_setAssociatedObject(self, &currentToastKey, nil, .OBJC_ASSOCIATION_RETAIN)
}
}
}
var toastParentView: UIView { view }
var toastScrollView: UIScrollView? { view as? UIScrollView }
func showToast(configuration config: ToastConfiguration, animated: Bool) {
currentToast?.dismissToast(animated: false)
var config = config
config.edge = effectiveEdge(edge: config.edge)
let toast = ToastView(configuration: config)
currentToast = toast
let parentSafeArea = toastParentView.safeAreaLayoutGuide
toast.translatesAutoresizingMaskIntoConstraints = false
toastParentView.addSubview(toast)
let yConstraint: NSLayoutConstraint
switch config.edge {
case .top:
yConstraint = toast.topAnchor.constraint(equalTo: parentSafeArea.topAnchor, constant: config.edgeSpacing)
case .bottom:
yConstraint = parentSafeArea.bottomAnchor.constraint(equalTo: toast.bottomAnchor, constant: config.edgeSpacing)
case .automatic:
fatalError("unreachable")
}
NSLayoutConstraint.activate([
toast.leadingAnchor.constraint(greaterThanOrEqualToSystemSpacingAfter: parentSafeArea.leadingAnchor, multiplier: 1),
parentSafeArea.trailingAnchor.constraint(greaterThanOrEqualToSystemSpacingAfter: toast.trailingAnchor, multiplier: 1),
parentSafeArea.centerXAnchor.constraint(equalTo: toast.centerXAnchor),
yConstraint,
])
if animated {
toast.animateAppearance()
}
if config.dismissOnScroll,
let scrollView = toastScrollView {
toast.setupDismissOnScroll(connectedTo: scrollView)
}
if let time = config.dismissAutomaticallyAfter {
DispatchQueue.main.asyncAfter(deadline: .now() + time) { [weak toast] in
guard let toast = toast, toast.shouldDismissAutomatically else { return }
toast.dismissToast(animated: true)
}
}
}
private func effectiveEdge(edge: ToastConfiguration.Edge) -> ToastConfiguration.Edge {
guard case .automatic = edge else {
return edge
}
if UIDevice.current.userInterfaceIdiom == .phone {
return .bottom
} else {
return .top
}
}
}
fileprivate class WeakHolder<T: AnyObject> {
weak var object: T?
init(object: T) {
self.object = object
}
}