Compare commits

...

63 Commits

Author SHA1 Message Date
Shadowfacts e61823b78f Update LIVC comments for iPhones 13 2021-09-19 12:43:38 -04:00
Shadowfacts 4d52ac4d34
Support new Mastodon instance configuration 2021-09-12 16:32:23 -04:00
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
Shadowfacts 6d8a014cc7 Bump build number and update changelog 2021-06-27 19:02:51 -04:00
Shadowfacts 60c88ded5e Require iOS 15 for Disable Infinite Scrolling 2021-06-27 17:17:39 -04:00
Shadowfacts 1e7a6af0bf Fix TimelineTableVC item hash including status state
Fixes crash when refreshing on iOS 14
2021-06-27 15:52:22 -04:00
Shadowfacts f8b79ef34f Fix app extension build number 2021-06-27 10:37:03 -04:00
Shadowfacts 4cf56685b5 Disable profile screen compose button when logged out 2021-06-27 10:31:02 -04:00
Shadowfacts fdcd2aa540 Add Open in New Window context menu action to sidebar items 2021-06-27 10:30:53 -04:00
Shadowfacts 667d30a710 Fix crash when editing accounts in a list
Closes #127
2021-06-26 18:54:59 -04:00
Shadowfacts b0f23e46ba Let Xcode update the stupid package name 2021-06-26 18:52:12 -04:00
Shadowfacts 9b30b48016 Bump build number and update changelog 2021-06-26 18:28:38 -04:00
Shadowfacts bd49683e13 Fix not being able to select assets on iOS 15 beta 2 2021-06-26 17:18:04 -04:00
Shadowfacts c22945b1e7 Use sheetPresentationController property 2021-06-26 17:02:17 -04:00
Shadowfacts 0a16a2e261 Fix potential data races 2021-06-26 16:51:54 -04:00
Shadowfacts b95819cada Fix crash when switching accounts 2021-06-26 16:42:56 -04:00
Shadowfacts dc1ea1bed9 Fix timeline momentum scrolling stopping due to adding footer section 2021-06-26 15:54:10 -04:00
Shadowfacts 5f9fe505d5 Add pref to disable infinite scrolling on timelines
Closes #125
2021-06-25 23:28:43 -04:00
Shadowfacts 5b8e97287e Convert TimelineTableViewController to use DiffableTimelineLikeTableViewController 2021-06-20 22:27:38 -04:00
Shadowfacts 49572c1fec Add DiffableTimelineLikeTableViewController 2021-06-20 22:27:29 -04:00
Shadowfacts ebb0770198 Add context menu action to remove attachments in Compose 2021-06-18 11:32:17 -04:00
Shadowfacts 27e05cc72d Enable focus loop debugging in debug 2021-06-12 22:17:59 -04:00
Shadowfacts 4ca48a5f50 Add iOS 15 compilation condition 2021-06-12 22:17:41 -04:00
Shadowfacts 230bd50661 Disable selection of presenting sidebar items on focus 2021-06-12 22:17:09 -04:00
Shadowfacts 4f2f8d517f Don't initiate table view cell drag while user is selecting poll options 2021-06-12 19:22:51 -04:00
Shadowfacts 130da9d4cc Improve status collapse animation
Use an additional label with no content and no height to absorb the
extra space creating during collapse when the content text view
disappears immediately.
2021-06-12 11:39:15 -04:00
Shadowfacts 472b9aa5e2 Fixes for large image animations on devices with square screns 2021-06-12 11:26:44 -04:00
Shadowfacts 3413dff8f9 Present compose screen in new window on iOS 15 and iPad/Mac 2021-06-11 10:50:31 -04:00
Shadowfacts 66e8fce488 Fix crash when conversation VC tries to restore from unloaded status 2021-06-11 10:19:59 -04:00
Shadowfacts aa2d333f4a Disable transparent nav bar on page view controllers 2021-06-10 10:55:09 -04:00
Shadowfacts c8a45d8eef Add Open in New Window menu item to profiles 2021-06-10 10:52:27 -04:00
Shadowfacts 40f5be28f6 Cleanup un/follow menu action 2021-06-10 10:36:02 -04:00
Shadowfacts 7c9287543c Fix crash due to PencilKit undo manager not being available until viewDidAppear 2021-06-10 10:33:24 -04:00
Shadowfacts 2a05b6d326 Add pointer hover effects to compose poll buttons 2021-06-09 19:18:54 -04:00
Shadowfacts 2499d25432 Use built-in sheet for asset picker on iOS 15 2021-06-09 19:12:10 -04:00
Shadowfacts 9417872790 Don't show Reply action in menu button on statuses 2021-06-09 17:10:44 -04:00
Shadowfacts c02a1bbf74 Make Pin status action title clearer 2021-06-09 17:10:13 -04:00
Shadowfacts 0a894b219a Allow Open in New Window action on iPadOS 2021-06-09 17:09:59 -04:00
Shadowfacts 22803668d2 Remove ellipsis from Share menu item title 2021-06-09 17:09:45 -04:00
Shadowfacts 2f6d1cb069 Use plain list style for Compose attachments 2021-06-09 17:08:59 -04:00
Shadowfacts 8889261b6b Fix compose reply avatar scroll effect not working on iOS 15 2021-06-09 11:01:11 -04:00
Shadowfacts 91f1a5195c Use visibility bar button item selection state instead of changing icon 2021-06-08 15:00:48 -04:00
Shadowfacts 1a5b958b1a Hide compose progress bar while there is no progress
On iOS 15, the progress bar displays a little bit of progress even at 0
2021-06-08 14:54:42 -04:00
Shadowfacts d667f6362c Use UniformTypeIdentifiers framework for everything 2021-06-07 20:08:46 -04:00
57 changed files with 2131 additions and 405 deletions

View File

@ -1,5 +1,65 @@
# 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)
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.
Features/Improvements:
- iPadOS 15: Add Open in New Window context menu action to sidebar items
Bugfixes:
- Fix crash when editing accounts in a list
- Fix crash when refreshing timeline on iOS 14
- Fix(ish) crash when opening collapsed status with Disable Infinite Scrolling active on iOS 15
## 2021.1 (20)
This is a big one! In addition to a bunch of fixes for anyone on the iOS 15 beta, there are a couple of big ticket features, including the Open in Tusker action extension and the Disable Infinite Scrolling preference.
Features/Improvements:
- Add Open in Tusker action extension
- Quickly search for any URL in Tusker
- In a share sheet, scroll to the bottom, tap "Edit Actions..." and turn on the "Open in Tusker" action
- Add Digital Wellness preference to disable infinite scrolling
- Add fast account switching indicator to My Profile tab
- Improve VoiceOver accessibility of polls and timeline statuses
- iPadOS: Create multiple main windows for different accounts by dragging from an account in Preferences
- iPadOS: Delete attachments on Compose screen by right-clicking and selecting Delete
- iPadOS 15: Add Open in New Window context menu action to most things
- iPadOS 15: Allow dragging the Compose sheet into a separate window
Bugfixes:
- Fix being unable to commit previewed account from timeline status
- Fix crash when searching fails
- Fix poll option percentages being cut off
- Fix polls not collapsing inside CWs
- Fix More button on profiles not being accessible with VoiceOver
- Fix VoiceOver reading profile fields in incorrect order
- Fix gallery animations jittering on devices with square screens (iPads, non-notched iPhones)
- Fix CW text jumping around post collapse animation
- iOS 15: Fix crash due when showing Draw Something screen in Compose
- iPadOS 14/iOS 15: Fix navigation bar turning transparent after opening the attachment gallery
- iPadOS 14/iOS 15: Fix drag-selecting poll options initiating a status cell drag interaction
- iPadOS: Fix crash when loading a previously-opened conversation window
- iPadOS 15: Fix showing Compose screen when keyboard focus moves through the sidebar
Known Issues:
- Disable Infinite Scrolling preference only affects timelines, not notifications or profiles
- iPadOS 15: The Compose sheet cannot be dismissed by swiping down
- iPadOS 15: Keyboard focus is stuck in the sidebar
## 2021.1 (19)
This is an emergency fix for Tusker breaking when connecting to Mastodon instances on 3.4.0rc1.

View File

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

View File

@ -18,12 +18,21 @@ public class Instance: Decodable {
public let thumbnail: URL?
public let languages: [String]?
public let stats: Stats?
public let configuration: Configuration?
// pleroma doesn't currently implement these
public let contactAccount: Account?
// MARK: Unofficial additions to the Mastodon API.
public let maxStatusCharacters: Int?
// superseded by mastodon's configuration.statuses.max_characters, still used by older instances & pleroma
let maxTootCharacters: Int?
let pollLimits: PollsConfiguration?
public var maxStatusCharacters: Int? {
configuration?.statuses.maxCharacters ?? maxTootCharacters
}
public var pollsConfiguration: PollsConfiguration? {
configuration?.polls ?? pollLimits
}
// we need a custom decoder, because all API-compatible implementations don't return some data in the same format
public required init(from decoder: Decoder) throws {
@ -44,14 +53,18 @@ public class Instance: Decodable {
self.stats = try? container.decodeIfPresent(Stats.self, forKey: .stats)
self.thumbnail = try? container.decodeIfPresent(URL.self, forKey: .thumbnail)
if let maxStatusCharacters = try? container.decodeIfPresent(Int.self, forKey: .maxStatusCharacters) {
self.maxStatusCharacters = maxStatusCharacters
} else if let str = try? container.decodeIfPresent(String.self, forKey: .maxStatusCharacters),
let maxStatusCharacters = Int(str, radix: 10) {
self.maxStatusCharacters = maxStatusCharacters
self.configuration = try? container.decodeIfPresent(Configuration.self, forKey: .configuration)
if let maxTootCharacters = try? container.decodeIfPresent(Int.self, forKey: .maxTootCharacters) {
self.maxTootCharacters = maxTootCharacters
} else if let str = try? container.decodeIfPresent(String.self, forKey: .maxTootCharacters),
let maxTootCharacters = Int(str, radix: 10) {
self.maxTootCharacters = maxTootCharacters
} else {
self.maxStatusCharacters = nil
self.maxTootCharacters = nil
}
self.pollLimits = try? container.decodeIfPresent(PollsConfiguration.self, forKey: .pollLimits)
}
@ -65,15 +78,16 @@ public class Instance: Decodable {
case thumbnail
case languages
case stats
case configuration
case contactAccount = "contact_account"
case maxStatusCharacters = "max_toot_chars"
case maxTootCharacters = "max_toot_chars"
case pollLimits = "poll_limits"
}
}
extension Instance {
public class Stats: Decodable {
public struct Stats: Decodable {
public let domainCount: Int?
public let statusCount: Int?
public let userCount: Int?
@ -85,3 +99,68 @@ extension Instance {
}
}
}
extension Instance {
public struct Configuration: Decodable {
public let statuses: StatusesConfiguration
public let mediaAttachments: MediaAttachmentsConfiguration
/// Use Instance.pollsConfiguration to support older instance that don't have this nested
let polls: PollsConfiguration
private enum CodingKeys: String, CodingKey {
case statuses
case mediaAttachments = "media_attachments"
case polls
}
}
}
extension Instance {
public struct StatusesConfiguration: Decodable {
public let maxCharacters: Int
public let maxMediaAttachments: Int
public let charactersReservedPerURL: Int
private enum CodingKeys: String, CodingKey {
case maxCharacters = "max_characters"
case maxMediaAttachments = "max_media_attachments"
case charactersReservedPerURL = "characters_reserved_per_url"
}
}
}
extension Instance {
public struct MediaAttachmentsConfiguration: Decodable {
public let supportedMIMETypes: [String]
public let imageSizeLimit: Int
public let imageMatrixLimit: Int
public let videoSizeLimit: Int
public let videoFrameRateLimit: Int
public let videoMatrixLimit: Int
private enum CodingKeys: String, CodingKey {
case supportedMIMETypes = "supported_mime_types"
case imageSizeLimit = "image_size_limit"
case imageMatrixLimit = "image_matrix_limit"
case videoSizeLimit = "video_size_limit"
case videoFrameRateLimit = "video_frame_rate_limit"
case videoMatrixLimit = "video_matrix_limit"
}
}
}
extension Instance {
public struct PollsConfiguration: Decodable {
public let maxOptions: Int
public let maxCharactersPerOption: Int
public let minExpiration: TimeInterval
public let maxExpiration: TimeInterval
private enum CodingKeys: String, CodingKey {
case maxOptions = "max_options"
case maxCharactersPerOption = "max_characters_per_option"
case minExpiration = "min_expiration"
case maxExpiration = "max_expiration"
}
}
}

View File

@ -8,7 +8,7 @@
import Foundation
public class NotificationGroup {
public class NotificationGroup: Identifiable, Hashable {
public let notifications: [Notification]
public let id: String
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] {
var groups = [[Notification]]()
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 */; };
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.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 */; };
D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C955213B365700269FB5 /* LargeImageExpandAnimationController.swift */; };
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */; };
D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C959213B5D0500269FB5 /* LargeImageInteractionController.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 */; };
D64BC18823C1640A000D0238 /* PinStatusActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18723C1640A000D0238 /* PinStatusActivity.swift */; };
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18923C16487000D0238 /* UnpinStatusActivity.swift */; };
@ -155,6 +160,7 @@
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */; };
D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */; };
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; };
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */; };
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */; };
D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; };
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; };
@ -304,6 +310,8 @@
D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; };
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; };
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */; };
D6DEA0DE268400C300FE896A /* ConfirmLoadMoreTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DEA0DC268400C300FE896A /* ConfirmLoadMoreTableViewCell.swift */; };
D6DEA0DF268400C300FE896A /* ConfirmLoadMoreTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6DEA0DD268400C300FE896A /* ConfirmLoadMoreTableViewCell.xib */; };
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */; };
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */; };
D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */; };
@ -532,11 +540,16 @@
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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
@ -553,6 +566,7 @@
D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentView.swift; sourceTree = "<group>"; };
D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentViewController.swift; sourceTree = "<group>"; };
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; };
D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffableTimelineLikeTableViewController.swift; sourceTree = "<group>"; };
D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundableViewController.swift; sourceTree = "<group>"; };
D65F612D23AE990C00F3CFD3 /* Embassy.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Embassy.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D65F613023AE99E000F3CFD3 /* Ambassador.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Ambassador.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -707,6 +721,8 @@
D6D4DDF1212518A200E1C4BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; };
D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Notification.swift"; sourceTree = "<group>"; };
D6DEA0DC268400C300FE896A /* ConfirmLoadMoreTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLoadMoreTableViewCell.swift; sourceTree = "<group>"; };
D6DEA0DD268400C300FE896A /* ConfirmLoadMoreTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfirmLoadMoreTableViewCell.xib; sourceTree = "<group>"; };
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelationshipMO.swift; sourceTree = "<group>"; };
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadScrollGestureRecognizer.swift; sourceTree = "<group>"; };
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakArray.swift; sourceTree = "<group>"; };
@ -1052,27 +1068,27 @@
D641C780213DD7C4004B4513 /* Screens */ = {
isa = PBXGroup;
children = (
D6C693FA2162FE5D007D6A6D /* Utilities */,
D6F2E960249E772F005846BB /* Crash Reporter */,
D641C782213DD7F0004B4513 /* Main */,
D641C783213DD7FE004B4513 /* Onboarding */,
D6A4DCC92553666600D9DE31 /* Fast Account Switcher */,
D641C781213DD7DD004B4513 /* Timeline */,
D641C784213DD819004B4513 /* Profile */,
D641C785213DD83B004B4513 /* Conversation */,
D641C786213DD852004B4513 /* Notifications */,
D641C787213DD862004B4513 /* Compose */,
D6A3BC822321F69400FD64D5 /* Account List */,
D6B053A023BD2BED00A066FA /* Asset Picker */,
0411610522B457290030A9B7 /* Attachment Gallery */,
D627944823A6AD5100D38C68 /* Bookmarks */,
D641C787213DD862004B4513 /* Compose */,
D641C785213DD83B004B4513 /* Conversation */,
D6F2E960249E772F005846BB /* Crash Reporter */,
D627FF77217E94F200CC0648 /* Drafts */,
D627943C23A5635D00D38C68 /* Explore */,
D6BC9DD8232D8BCA002CA326 /* Search */,
D627944B23A9A02400D38C68 /* Lists */,
D6A4DCC92553666600D9DE31 /* Fast Account Switcher */,
D641C788213DD86D004B4513 /* Large Image */,
0411610522B457290030A9B7 /* Attachment Gallery */,
D6A3BC822321F69400FD64D5 /* Account List */,
D6A3BC8C2321FF9B00FD64D5 /* Status Action Account List */,
D627944823A6AD5100D38C68 /* Bookmarks */,
D627944B23A9A02400D38C68 /* Lists */,
D641C782213DD7F0004B4513 /* Main */,
D641C786213DD852004B4513 /* Notifications */,
D641C783213DD7FE004B4513 /* Onboarding */,
D641C789213DD87E004B4513 /* Preferences */,
D641C784213DD819004B4513 /* Profile */,
D6BC9DD8232D8BCA002CA326 /* Search */,
D6A3BC8C2321FF9B00FD64D5 /* Status Action Account List */,
D641C781213DD7DD004B4513 /* Timeline */,
D6C693FA2162FE5D007D6A6D /* Utilities */,
);
path = Screens;
sourceTree = "<group>";
@ -1234,6 +1250,15 @@
path = Notifications;
sourceTree = "<group>";
};
D6420AEB26BED17500ED8175 /* Timeline Description Cell */ = {
isa = PBXGroup;
children = (
D6420AEC26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift */,
D6420AED26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.xib */,
);
path = "Timeline Description Cell";
sourceTree = "<group>";
};
D646C954213B364600269FB5 /* Transitions */ = {
isa = PBXGroup;
children = (
@ -1244,6 +1269,16 @@
path = Transitions;
sourceTree = "<group>";
};
D64AAE8F26C80DB600FC57FB /* Toast */ = {
isa = PBXGroup;
children = (
D64AAE9026C80DC600FC57FB /* ToastView.swift */,
D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */,
D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */,
);
path = Toast;
sourceTree = "<group>";
};
D65A37F221472F300087646E /* Frameworks */ = {
isa = PBXGroup;
children = (
@ -1435,34 +1470,37 @@
D6BED1722126661300F02DA0 /* Views */ = {
isa = PBXGroup;
children = (
D620483323D3801D008A63EF /* LinkTextView.swift */,
D620483523D38075008A63EF /* ContentTextView.swift */,
D620483723D38190008A63EF /* StatusContentTextView.swift */,
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */,
D620483523D38075008A63EF /* ContentTextView.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
D6969E9F240C8384002843CE /* EmojiLabel.swift */,
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */,
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */,
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */,
04ED00B021481ED800567C53 /* SteppedProgressView.swift */,
D620483323D3801D008A63EF /* LinkTextView.swift */,
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */,
D620483723D38190008A63EF /* StatusContentTextView.swift */,
04ED00B021481ED800567C53 /* SteppedProgressView.swift */,
D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */,
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */,
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */,
D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */,
D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */,
D6A3BC872321F78000FD64D5 /* Account Cell */,
D67C57A721E2649B00C3118B /* Account Detail */,
D626494023C122C800612E6E /* Asset Picker */,
D61959D0241E842400A37B8E /* Draft Cell */,
D641C78A213DD926004B4513 /* Status */,
D6C7D27B22B6EBE200071952 /* Attachments */,
D623A53B2635F4E20095BD04 /* Poll */,
D641C78B213DD92F004B4513 /* Profile Header */,
D641C78C213DD937004B4513 /* Notifications */,
D6A3BC872321F78000FD64D5 /* Account Cell */,
D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */,
D61959D0241E842400A37B8E /* Draft Cell */,
D611C2CC232DC5FC00C86A49 /* Hashtag Cell */,
D61AC1DA232EA43100C54D2D /* Instance Cell */,
D641C78C213DD937004B4513 /* Notifications */,
D623A53B2635F4E20095BD04 /* Poll */,
D641C78B213DD92F004B4513 /* Profile Header */,
D641C78A213DD926004B4513 /* Status */,
D64AAE8F26C80DB600FC57FB /* Toast */,
D6420AEB26BED17500ED8175 /* Timeline Description Cell */,
);
path = Views;
sourceTree = "<group>";
@ -1470,23 +1508,24 @@
D6C693FA2162FE5D007D6A6D /* Utilities */ = {
isa = PBXGroup;
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 */,
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */,
D65234C8256189D0001AF9CF /* TimelineLikeTableViewController.swift */,
D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.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 */,
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;
sourceTree = "<group>";
@ -1533,36 +1572,36 @@
isa = PBXGroup;
children = (
D6E4885C24A2890C0011C13E /* Tusker.entitlements */,
D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */,
D6D4DDDB212518A200E1C4BB /* Info.plist */,
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */,
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */,
D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */,
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */,
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D64D0AAC2128D88B005A6F37 /* LocalData.swift */,
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.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 */,
D6AEBB3F2321640F00E5038B /* Activities */,
D6F1F84E2193B9BE00F5FE67 /* Caching */,
D6F953F121251A2F00CF0F2B /* Controllers */,
D6370B9924421FE00092A7FF /* CoreData */,
D667E5F62135C2ED0057A976 /* Extensions */,
D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */,
D6D4DDDB212518A200E1C4BB /* Info.plist */,
D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */,
D6E57FA525C26FAB00341037 /* Localizable.stringsdict */,
D61959D2241E846D00A37B8E /* Models */,
D663626021360A9600C9CBA2 /* Preferences */,
D641C780213DD7C4004B4513 /* Screens */,
D62D241E217AA46B005076CC /* Shortcuts */,
D67B506B250B28FF00FAECFB /* Vendor */,
D6BED1722126661300F02DA0 /* Views */,
D6757A7A2157E00100721E32 /* XCallbackURL */,
);
path = Tusker;
sourceTree = "<group>";
@ -1592,6 +1631,15 @@
path = TuskerUITests;
sourceTree = "<group>";
};
D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */ = {
isa = PBXGroup;
children = (
D6DEA0DC268400C300FE896A /* ConfirmLoadMoreTableViewCell.swift */,
D6DEA0DD268400C300FE896A /* ConfirmLoadMoreTableViewCell.xib */,
);
path = "Confirm Load More Cell";
sourceTree = "<group>";
};
D6E343A9265AAD6B00C4AA01 /* OpenInTusker */ = {
isa = PBXGroup;
children = (
@ -1869,6 +1917,7 @@
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */,
D6C82B5725C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib in Resources */,
D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */,
D6420AEF26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.xib in Resources */,
D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */,
D6B053AC23BD2F1400A066FA /* AssetCollectionViewCell.xib in Resources */,
D6D4DDD7212518A200E1C4BB /* Assets.xcassets in Resources */,
@ -1886,6 +1935,7 @@
D6F2E966249E8BFD005846BB /* CrashReporterViewController.xib in Resources */,
D662AEF0263A3B880082A153 /* PollFinishedTableViewCell.xib in Resources */,
D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */,
D6DEA0DF268400C300FE896A /* ConfirmLoadMoreTableViewCell.xib in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -2019,6 +2069,7 @@
D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */,
D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */,
D6093FB725BE0CF3004811E6 /* HashtagHistoryView.swift in Sources */,
D6DEA0DE268400C300FE896A /* ConfirmLoadMoreTableViewCell.swift in Sources */,
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */,
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */,
D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */,
@ -2033,6 +2084,7 @@
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */,
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */,
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */,
D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */,
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */,
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */,
@ -2114,6 +2166,7 @@
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */,
D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */,
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */,
D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */,
D6093FB025BE0B01004811E6 /* TrendingHashtagTableViewCell.swift in Sources */,
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */,
D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */,
@ -2128,6 +2181,8 @@
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */,
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */,
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */,
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */,
D6420AEE26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift in Sources */,
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */,
D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */,
@ -2151,6 +2206,7 @@
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */,
D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */,
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
@ -2489,6 +2545,7 @@
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES;
};
name = Debug;
};
@ -2545,6 +2602,7 @@
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES;
VALIDATE_PRODUCT = YES;
};
name = Release;
@ -2557,7 +2615,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 19;
CURRENT_PROJECT_VERSION = 22;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2573,6 +2631,10 @@
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTS_MACCATALYST = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos15.0]" = "SDK_IOS_15 $(inherited)";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphonesimulator15.0]" = "SDK_IOS_15 $(inherited)";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=macosx12.0]" = "SDK_IOS_15 $(inherited)";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,6";
};
@ -2586,7 +2648,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 19;
CURRENT_PROJECT_VERSION = 22;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2695,7 +2757,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 19;
CURRENT_PROJECT_VERSION = 22;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2722,7 +2784,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 19;
CURRENT_PROJECT_VERSION = 22;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;

View File

@ -88,6 +88,10 @@
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "YES">
</CommandLineArgument>
<CommandLineArgument
argument = "-UIFocusLoopDebuggerEnabled YES"
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable

View File

@ -2,7 +2,7 @@
"object": {
"pins": [
{
"package": "PLCrashReporter",
"package": "plcrashreporter",
"repositoryURL": "https://github.com/microsoft/plcrashreporter",
"state": {
"branch": null,

View File

@ -17,21 +17,39 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate {
return
}
guard LocalData.shared.onboardingComplete else {
UIApplication.shared.requestSceneSessionDestruction(session, options: nil, errorHandler: nil)
return
}
let account: LocalData.UserAccountInfo
let controller: MastodonController
let draft: Draft?
if let activity = connectionOptions.userActivities.first ?? session.stateRestorationActivity,
let activityAccount = UserActivityManager.getAccount(from: activity) {
if let activity = connectionOptions.userActivities.first ?? session.stateRestorationActivity {
if let activityAccount = UserActivityManager.getAccount(from: activity) {
account = activityAccount
draft = UserActivityManager.getDraft(from: activity)
} else {
// todo: this potentially changes the account for the draft, should show the same warning to user as in the drafts selection screen
account = LocalData.shared.getMostRecentAccount()!
}
controller = MastodonController.getForAccount(account)
if let activityDraft = UserActivityManager.getDraft(from: activity) {
draft = activityDraft
} else if let mentioning = activity.userInfo?["mentioning"] as? String {
draft = controller.createDraft(inReplyToID: nil, mentioningAcct: mentioning)
} else {
draft = nil
}
} else {
account = LocalData.shared.getMostRecentAccount()!
controller = MastodonController.getForAccount(account)
draft = nil
}
let controller = MastodonController.getForAccount(account)
session.mastodonController = controller
controller.getOwnAccount()
controller.getOwnInstance()

View File

@ -8,7 +8,7 @@
import Foundation
import UIKit
import MobileCoreServices
import UniformTypeIdentifiers
final class CompositionAttachment: NSObject, Codable, ObservableObject {
static let typeIdentifier = "space.vaccor.Tusker.composition-attachment"
@ -52,10 +52,10 @@ final class CompositionAttachment: NSObject, Codable, ObservableObject {
extension CompositionAttachment: Identifiable {}
private let imageType = kUTTypeImage as String
private let mp4Type = kUTTypeMPEG4 as String
private let quickTimeType = kUTTypeQuickTimeMovie as String
private let dataType = kUTTypeData as String
private let imageType = UTType.image.identifier
private let mp4Type = UTType.mpeg4Movie.identifier
private let quickTimeType = UTType.quickTimeMovie.identifier
private let dataType = UTType.data.identifier
extension CompositionAttachment: NSItemProviderWriting {
static var writableTypeIdentifiersForItemProvider: [String] {
@ -100,11 +100,11 @@ extension CompositionAttachment: NSItemProviderReading {
return try PropertyListDecoder().decode(Self.self, from: data)
} else if UIImage.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let image = try? UIImage.object(withItemProviderData: data, typeIdentifier: typeIdentifier) {
return CompositionAttachment(data: .image(image)) as! Self
} else if typeIdentifier == mp4Type || typeIdentifier == quickTimeType {
} else if let type = UTType(typeIdentifier), type == .mpeg4Movie || type == .quickTimeMovie {
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let temporaryFileName = ProcessInfo().globallyUniqueString
let fileExt = UTTypeCopyPreferredTagWithClass(typeIdentifier as CFString, kUTTagClassFilenameExtension)!
let temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(temporaryFileName).appendingPathExtension(fileExt.takeUnretainedValue() as String)
let fileExt = type.preferredFilenameExtension!
let temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(temporaryFileName).appendingPathExtension(fileExt)
try data.write(to: temporaryFileURL)
return CompositionAttachment(data: .video(temporaryFileURL)) as! Self
} else if NSURL.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let url = try? NSURL.object(withItemProviderData: data, typeIdentifier: typeIdentifier) as URL {

View File

@ -8,7 +8,7 @@
import UIKit
import Photos
import MobileCoreServices
import UniformTypeIdentifiers
import PencilKit
enum CompositionAttachmentData {
@ -74,7 +74,7 @@ enum CompositionAttachmentData {
data = context.jpegRepresentation(of: image, colorSpace: colorSpace, options: [:])!
mimeType = "image/jpeg"
} else {
mimeType = UTTypeCopyPreferredTagWithClass(dataUTI as CFString, kUTTagClassMIMEType)!.takeRetainedValue() as String
mimeType = UTType(dataUTI)!.preferredMIMEType!
}
completion(data, mimeType)

View File

@ -63,9 +63,13 @@ class Preferences: Codable, ObservableObject {
self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
self.grayscaleImages = try container.decodeIfPresent(Bool.self, forKey: .grayscaleImages) ?? false
self.disableInfiniteScrolling = try container.decodeIfPresent(Bool.self, forKey: .disableInfiniteScrolling) ?? false
self.silentActions = try container.decode([String: Permission].self, forKey: .silentActions)
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 {
@ -97,9 +101,13 @@ class Preferences: Codable, ObservableObject {
try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts)
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
try container.encode(grayscaleImages, forKey: .grayscaleImages)
try container.encode(disableInfiniteScrolling, forKey: .disableInfiniteScrolling)
try container.encode(silentActions, forKey: .silentActions)
try container.encode(statusContentType, forKey: .statusContentType)
try container.encode(hasShownLocalTimelineDescription, forKey: .hasShownLocalTimelineDescription)
try container.encode(hasShownFederatedTimelineDescription, forKey: .hasShownFederatedTimelineDescription)
}
// MARK: Appearance
@ -133,11 +141,16 @@ class Preferences: Codable, ObservableObject {
@Published var showFavoriteAndReblogCounts = true
@Published var defaultNotificationsMode = NotificationsMode.allNotifications
@Published var grayscaleImages = false
@Published var disableInfiniteScrolling = false
// MARK: Advanced
@Published var silentActions: [String: Permission] = [:]
@Published var statusContentType: StatusContentType = .plain
// MARK:
@Published var hasShownLocalTimelineDescription = false
@Published var hasShownFederatedTimelineDescription = false
private enum CodingKeys: String, CodingKey {
case theme
case avatarStyle
@ -165,9 +178,13 @@ class Preferences: Codable, ObservableObject {
case showFavoriteAndReblogCounts
case defaultNotificationsType
case grayscaleImages
case disableInfiniteScrolling
case silentActions
case statusContentType
case hasShownLocalTimelineDescription
case hasShownFederatedTimelineDescription
}
}

View File

@ -70,6 +70,7 @@ class AssetCollectionViewController: UICollectionViewController {
collectionView.alwaysBounceVertical = true
collectionView.allowsMultipleSelection = true
collectionView.allowsSelection = true
collectionView.register(UINib(nibName: "AssetCollectionViewCell", bundle: .main), forCellWithReuseIdentifier: reuseIdentifier)
collectionView.register(UINib(nibName: "ShowCameraCollectionViewCell", bundle: .main), forCellWithReuseIdentifier: cameraReuseIdentifier)
@ -98,8 +99,6 @@ class AssetCollectionViewController: UICollectionViewController {
}
})
setEditing(true, animated: false)
updateItemsSelectedCount()
if let singleFingerPanGesture = collectionView.gestureRecognizers?.first(where: {

View File

@ -51,8 +51,14 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
}
var dismissInteractionController: LargeImageInteractionController?
var isInteractivelyAnimatingDismissal: Bool = false {
didSet {
setNeedsStatusBarAppearanceUpdate()
}
}
override var prefersStatusBarHidden: Bool {
return true
return !isInteractivelyAnimatingDismissal
}
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
return .none

View File

@ -41,6 +41,16 @@ struct ComposeAttachmentRow: View {
Label("Recognize Text", systemImage: "doc.text.viewfinder")
}
}
if #available(iOS 15.0, *) {
Button(action: self.removeAttachment) {
Label("Delete", systemImage: "trash")
}.foregroundStyle(.red)
} else {
Button(action: self.removeAttachment) {
Label("Delete", systemImage: "trash")
}
}
}
switch mode {
@ -126,8 +136,10 @@ struct ComposeAttachmentRow: View {
}
private func removeAttachment() {
withAnimation {
draft.attachments.removeAll { $0.id == attachment.id }
}
}
private func editDrawing() {
uiState.composeDrawingMode = .edit(id: attachment.id)

View File

@ -69,6 +69,7 @@ struct ComposeAttachmentsList: View {
.frame(height: cellHeight / 2)
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
}
.listStyle(PlainListStyle())
.frame(height: totalListHeight)
.onAppear(perform: self.didAppear)
.onReceive(draft.$attachments, perform: self.attachmentsChanged)

View File

@ -80,13 +80,14 @@ class ComposeDrawingViewController: UIViewController {
updateLayout(for: toolPicker)
canvasView.becomeFirstResponder()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// wait until the next run loop iteration so that the canvas view has become first responder and it's undo manager exists
DispatchQueue.main.async {
NotificationCenter.default.addObserver(self, selector: #selector(self.updateUndoRedoButtonState), name: .NSUndoManagerDidUndoChange, object: self.undoManager!)
NotificationCenter.default.addObserver(self, selector: #selector(self.updateUndoRedoButtonState), name: .NSUndoManagerDidRedoChange, object: self.undoManager!)
}
}
func updateLayout(for toolPicker: PKToolPicker) {
let obscuredFrame = toolPicker.frameObscured(in: view)

View File

@ -245,6 +245,25 @@ extension ComposeHostingController: ComposeUIStateDelegate {
}
func presentAssetPickerSheet() {
#if SDK_IOS_15
if #available(iOS 15.0, *) {
let picker = AssetPickerViewController()
picker.assetPickerDelegate = self
picker.modalPresentationStyle = .pageSheet
picker.overrideUserInterfaceStyle = .dark
let sheet = picker.sheetPresentationController!
sheet.detents = [.medium(), .large()]
sheet.prefersEdgeAttachedInCompactHeight = true
self.present(picker, animated: true)
} else {
presentOldAssetPickerSheet()
}
#else
presentOldAssetPickerSheet()
#endif
}
private func presentOldAssetPickerSheet() {
let sheetContainer = AssetPickerSheetContainerViewController()
sheetContainer.assetPicker.assetPickerDelegate = self
self.present(sheetContainer, animated: true)

View File

@ -46,6 +46,7 @@ struct ComposePollView: View {
}
.accentColor(buttonForegroundColor)
.background(Circle().foregroundColor(buttonBackgroundColor))
.hoverEffect()
}
ForEach(Array(poll.options.enumerated()), id: \.element.id) { (e) in
@ -161,6 +162,7 @@ struct ComposePollOption: View {
}
.foregroundColor(poll.options.count == 1 ? .gray : .red)
.disabled(poll.options.count == 1)
.hoverEffect()
}
}

View File

@ -52,9 +52,20 @@ struct ComposeReplyView: View {
private func replyAvatarImage(geometry: GeometryProxy) -> some View {
// using named coordinate spaces produces an incorrect scroll offset on iOS 13,
// so simply compare the geometry inside and outside the scroll view in the global coordinate space
var scrollOffset = outerMinY - geometry.frame(in: .global).minY
scrollOffset += stackPadding
let offset = min(max(scrollOffset, 0), geometry.size.height - 50 - stackPadding)
let scrollOffset = outerMinY - geometry.frame(in: .global).minY
// add stackPadding so that the image is always at least stackPadding away from the top
var offset = scrollOffset + stackPadding
// offset can never be less than 0 (i.e., above the top of the in-reply-to content)
offset = max(offset, 0)
// subtract 50, because we care about where the bottom of the view is but the offset is relative to the top of the view
let maxOffset = (contentHeight ?? 0) - 50
// once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content
offset = min(offset, maxOffset)
return ComposeAvatarImageView(url: status.account.avatar)
.frame(width: 50, height: 50)
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)

View File

@ -58,8 +58,10 @@ struct ComposeView: View {
}
}
if postProgress > 0 {
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
WrappedProgressView(value: postProgress, total: postTotalProgress)
}
autocompleteSuggestions
}

View File

@ -132,6 +132,30 @@ class ConversationTableViewController: EnhancedTableViewController {
visibilityBarButtonItem = UIBarButtonItem(image: initialImage, style: .plain, target: self, action: #selector(toggleVisibilityButtonPressed))
navigationItem.rightBarButtonItem = visibilityBarButtonItem
loadMainStatus()
}
private func loadMainStatus() {
if let mainStatus = mastodonController.persistentContainer.status(for: mainStatusID) {
self.mainStatusLoaded(mainStatus)
} else {
let request = Client.getStatus(id: mainStatusID)
mastodonController.run(request) { (response) in
switch response {
case let .success(status, _):
let viewContext = self.mastodonController.persistentContainer.viewContext
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false, context: viewContext) { (statusMO) in
self.mainStatusLoaded(statusMO)
}
case .failure(_):
fatalError()
}
}
}
}
private func mainStatusLoaded(_ mainStatus: StatusMO) {
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState)
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
@ -139,12 +163,10 @@ class ConversationTableViewController: EnhancedTableViewController {
snapshot.appendItems([mainStatusItem], toSection: .statuses)
dataSource.apply(snapshot, animatingDifferences: false)
guard let mainStatus = self.mastodonController.persistentContainer.status(for: self.mainStatusID) else {
fatalError("Missing cached status \(self.mainStatusID)")
}
let mainStatusInReplyToID = mainStatus.inReplyToID
mainStatus.incrementReferenceCount()
// todo: it would be nice to cache these contexts
let request = Status.getContext(mainStatusID)
mastodonController.run(request) { response in
guard case let .success(context, _) = response else { fatalError() }
@ -304,7 +326,16 @@ class ConversationTableViewController: EnhancedTableViewController {
}
@objc func toggleVisibilityButtonPressed() {
#if SDK_IOS_15
if #available(iOS 15.0, *) {
visibilityBarButtonItem.isSelected = !visibilityBarButtonItem.isSelected
showStatusesAutomatically = visibilityBarButtonItem.isSelected
} else {
showStatusesAutomatically = !showStatusesAutomatically
}
#else
showStatusesAutomatically = !showStatusesAutomatically
#endif
let snapshot = dataSource.snapshot()
for case let .status(id: _, state: state) in snapshot.itemIdentifiers where state.collapsible == true {

View File

@ -73,12 +73,12 @@ class FastAccountSwitcherViewController: UIViewController {
accountView.alpha = 0
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.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
}
}

View File

@ -51,8 +51,14 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
private var prevZoomScale: CGFloat?
private var isGrayscale = false
var isInteractivelyAnimatingDismissal: Bool = false {
didSet {
setNeedsStatusBarAppearanceUpdate()
}
}
override var prefersStatusBarHidden: Bool {
return true
return !isInteractivelyAnimatingDismissal
}
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
return .none
@ -137,11 +143,14 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
let notchedDeviceTopInsets: [CGFloat] = [
44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max
48, // iPhone XR, 11
47, // iPhone 12, 12 Pro, 12 Pro Max
50, // iPhone 12 mini
47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max
50, // iPhone 12 mini, 13 mini
]
if notchedDeviceTopInsets.contains(view.safeAreaInsets.top) {
let notchWidth: CGFloat = 209
// the notch width is not the same for the iPhones 13,
// but what we actually want is the same offset from the edges
// since the corner radius didn't change
let notchWidth: CGFloat = 210
let earWidth = (view.bounds.width - notchWidth) / 2
let offset = (earWidth - shareButton.bounds.width) / 2
shareButtonLeadingConstraint.constant = offset

View File

@ -43,8 +43,14 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
var animationGifData: Data? { largeImageVC?.animationGifData }
var dismissInteractionController: LargeImageInteractionController?
var isInteractivelyAnimatingDismissal: Bool = false {
didSet {
setNeedsStatusBarAppearanceUpdate()
}
}
override var prefersStatusBarHidden: Bool {
return true
return !isInteractivelyAnimatingDismissal
}
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
return .none

View File

@ -15,6 +15,7 @@ protocol LargeImageAnimatableViewController: UIViewController {
var animationImage: UIImage? { get }
var animationGifData: Data? { get }
var dismissInteractionController: LargeImageInteractionController? { get }
var isInteractivelyAnimatingDismissal: Bool { get set }
}
extension LargeImageAnimatableViewController {
@ -74,7 +75,7 @@ class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTra
toVC.largeImageController?.contentView.isHidden = true
toVC.largeImageController?.setControlsVisible(false, animated: false)
var finalFrameSize = finalVCFrame.inset(by: fromVC.view.safeAreaInsets).size
var finalFrameSize = finalVCFrame.inset(by: toVC.view.safeAreaInsets).size
let newWidth = finalFrameSize.width / image.size.width
let newHeight = finalFrameSize.height / image.size.height
if newHeight < newWidth {

View File

@ -14,9 +14,9 @@ class LargeImageInteractionController: UIPercentDrivenInteractiveTransition {
var direction: CGFloat?
var shouldCompleteTransition = false
private weak var viewController: UIViewController!
private weak var viewController: LargeImageAnimatableViewController!
init(viewController: UIViewController) {
init(viewController: LargeImageAnimatableViewController) {
super.init()
self.viewController = viewController
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handleGesture(_:)))
@ -42,6 +42,7 @@ class LargeImageInteractionController: UIPercentDrivenInteractiveTransition {
viewController.dismiss(animated: true)
case .changed:
shouldCompleteTransition = progress > 0.5 || velocity > 1000
viewController.isInteractivelyAnimatingDismissal = progress > 0.1
update(progress)
case .cancelled:
inProgress = false
@ -59,4 +60,9 @@ class LargeImageInteractionController: UIPercentDrivenInteractiveTransition {
}
}
override func cancel() {
super.cancel()
viewController.isInteractivelyAnimatingDismissal = false
}
}

View File

@ -80,8 +80,7 @@ class EditListAccountsViewController: EnhancedTableViewController {
self.nextRange = pagination?.older
self.mastodonController.persistentContainer.addAll(accounts: accounts)
self.mastodonController.persistentContainer.addAll(accounts: accounts) {
var snapshot = self.dataSource.snapshot()
snapshot.deleteSections([.accounts])
snapshot.appendSections([.accounts])
@ -92,6 +91,7 @@ class EditListAccountsViewController: EnhancedTableViewController {
}
}
}
}
// MARK: - Table view delegate

View File

@ -58,7 +58,7 @@ class ListTimelineViewController: TimelineTableViewController {
dismiss(animated: true)
// todo: show loading indicator
reloadInitialItems()
reloadInitial()
}
}

View File

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

View File

@ -448,6 +448,35 @@ extension MainSidebarViewController: UICollectionViewDelegate {
sidebarDelegate?.sidebar(self, didSelectItem: item)
}
}
@available(iOS 15.0, *)
func collectionView(_ collectionView: UICollectionView, selectionFollowsFocusForItemAt indexPath: IndexPath) -> Bool {
guard let item = dataSource.itemIdentifier(for: indexPath) else {
return true
}
// don't immediately select items that present VCs when the they're focused, only when deliberately selected
switch item {
case .tab(.compose), .addList, .addSavedHashtag, .addSavedInstance:
return false
default:
return true
}
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard #available(iOS 15.0, *),
let item = dataSource.itemIdentifier(for: indexPath),
let activity = userActivityForItem(item) else {
return nil
}
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) in
return UIMenu(children: [
UIWindowScene.ActivationAction({ action in
return UIWindowScene.ActivationConfiguration(userActivity: activity)
}),
])
}
}
}
extension MainSidebarViewController: UICollectionViewDragDelegate {

View File

@ -353,11 +353,18 @@ fileprivate extension MainSidebarViewController.Item {
extension MainSplitViewController: TuskerRootViewController {
@objc func presentCompose() {
if #available(iOS 15.0, *), UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
let compose = UserActivityManager.newPostActivity(mentioning: nil, accountID: mastodonController.accountInfo!.id)
let options = UIWindowScene.ActivationRequestOptions()
options.preferredPresentationStyle = .prominent
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil)
} else {
let vc = ComposeHostingController(mastodonController: mastodonController)
let nav = EnhancedNavigationViewController(rootViewController: vc)
nav.presentationController?.delegate = vc
present(nav, animated: true)
}
}
func select(tab: MainTabBarViewController.Tab) {
if traitCollection.horizontalSizeClass == .compact {

View File

@ -212,11 +212,18 @@ extension MainTabBarViewController: FastAccountSwitcherViewControllerDelegate {
extension MainTabBarViewController: TuskerRootViewController {
@objc func presentCompose() {
if #available(iOS 15.0, *), UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
let compose = UserActivityManager.newPostActivity(mentioning: nil, accountID: mastodonController.accountInfo!.id)
let options = UIWindowScene.ActivationRequestOptions()
options.preferredPresentationStyle = .prominent
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil)
} else {
let vc = ComposeHostingController(mastodonController: mastodonController)
let nav = EnhancedNavigationViewController(rootViewController: vc)
nav.presentationController?.delegate = vc
present(nav, animated: true)
}
}
func select(tab: Tab) {
if tab == .compose {

View File

@ -9,7 +9,7 @@
import UIKit
import Pachyderm
class NotificationsTableViewController: TimelineLikeTableViewController<NotificationGroup> {
class NotificationsTableViewController: DiffableTimelineLikeTableViewController<NotificationsTableViewController.Section, NotificationGroup> {
private let statusCell = "statusCell"
private let actionGroupCell = "actionGroupCell"
@ -54,88 +54,9 @@ class NotificationsTableViewController: TimelineLikeTableViewController<Notifica
tableView.register(UINib(nibName: "BasicTableViewCell", bundle: .main), forCellReuseIdentifier: unknownCell)
}
override func loadInitialItems(completion: @escaping ([NotificationGroup]) -> Void) {
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)
// MARK: - DiffableTimelineLikeTableViewController
override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ group: NotificationGroup) -> UITableViewCell? {
switch group.kind {
case .mention:
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
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 {
var apiController: MastodonController { mastodonController }
}
@ -224,7 +257,8 @@ extension NotificationsTableViewController: StatusTableViewCellDelegate {
extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
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)
}
}
@ -232,7 +266,8 @@ extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
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)
}
}

View File

@ -29,6 +29,8 @@ class InstanceSelectorTableViewController: UITableViewController {
var urlHandler: AnyCancellable?
var currentQuery: String?
private var activityIndicator: UIActivityIndicatorView!
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
if UIDevice.current.userInterfaceIdiom == .phone {
return .portrait
@ -50,10 +52,15 @@ class InstanceSelectorTableViewController: UITableViewController {
override func 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.estimatedRowHeight = 120
createActivityIndicatorHeader()
dataSource = DataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
switch item {
@ -73,12 +80,19 @@ class InstanceSelectorTableViewController: UITableViewController {
searchController.obscuresBackgroundDuringPresentation = false
searchController.searchBar.searchTextField.autocapitalizationType = .none
navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false
definesPresentationContext = true
urlHandler = urlCheckerSubject
.debounce(for: .seconds(1), scheduler: RunLoop.main)
.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()
}
@ -112,6 +126,8 @@ class InstanceSelectorTableViewController: UITableViewController {
}
private func updateSpecificInstance(domain: String) {
activityIndicator.startAnimating()
let components = parseURLComponents(input: domain)
let url = components.url!
@ -120,16 +136,26 @@ class InstanceSelectorTableViewController: UITableViewController {
client.run(request) { (response) in
var snapshot = self.dataSource.snapshot()
if snapshot.indexOfSection(.selected) != nil {
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .selected))
snapshot.deleteSections([.selected])
}
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.appendItems([.selected(url, instance)], toSection: .selected)
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() {
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.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]
if let currentQuery = currentQuery, !currentQuery.isEmpty {
filteredInstances = recommendedInstances.filter {
@ -155,13 +236,21 @@ class InstanceSelectorTableViewController: UITableViewController {
}
var snapshot = self.dataSource.snapshot()
snapshot.deleteSections([.recommendedInstances])
snapshot.appendSections([.recommendedInstances])
snapshot.appendItems(filteredInstances.map { Item.recommended($0) }, toSection: .recommendedInstances)
DispatchQueue.main.async {
self.dataSource.apply(snapshot)
if snapshot.indexOfSection(.recommendedInstances) != nil {
let toRemove = snapshot.itemIdentifiers(inSection: .recommendedInstances).filter {
if case .recommended(_) = $0 {
return true
} 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
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
@ -194,29 +283,30 @@ extension InstanceSelectorTableViewController {
case recommended(InstanceSelector.Instance)
static func ==(lhs: Item, rhs: Item) -> Bool {
if case let .selected(url, instance) = lhs,
case let .selected(otherUrl, other) = rhs {
return url == otherUrl && instance.uri == other.uri
} else if case let .recommended(instance) = lhs,
case let .recommended(other) = rhs {
return instance.domain == other.domain
}
switch (lhs, rhs) {
case let (.selected(urlA, instanceA), .selected(urlB, instanceB)):
return urlA == urlB && instanceA.uri == instanceB.uri
case let (.recommended(a), .recommended(b)):
return a.domain == b.domain
default:
return false
}
}
func hash(into hasher: inout Hasher) {
switch self {
case let .selected(url, instance):
hasher.combine(Section.selected)
hasher.combine(0)
hasher.combine(url)
hasher.combine(instance.uri)
case let .recommended(instance):
hasher.combine(Section.recommendedInstances)
hasher.combine(1)
hasher.combine(instance.domain)
}
}
}
class DataSource: UITableViewDiffableDataSource<Section, Item> {
}
}

View File

@ -16,6 +16,9 @@ struct WellnessPrefsView: View {
showFavAndReblogCount
notificationsMode
grayscaleImages
if #available(iOS 15.0, *) {
disableInfiniteScrolling
}
}
.listStyle(InsetGroupedListStyle())
.navigationBarTitle(Text("Digital Wellness"))
@ -46,6 +49,14 @@ struct WellnessPrefsView: View {
}
}
}
private var disableInfiniteScrolling: some View {
Section(footer: Text("Require a button tap before loading more posts.")) {
Toggle(isOn: $preferences.disableInfiniteScrolling) {
Text("Disable Infinite Scrolling")
}
}
}
}
struct WellnessPrefsView_Previews: PreviewProvider {

View File

@ -70,10 +70,11 @@ class ProfileViewController: UIPageViewController {
let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning))
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
self.composeDirectMentioning()
UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] (_) in
self?.composeDirectMentioning()
})
])
composeButton.isEnabled = mastodonController.loggedIn
navigationItem.rightBarButtonItem = composeButton
headerView = ProfileHeaderView.create()
@ -91,11 +92,18 @@ class ProfileViewController: UIPageViewController {
addKeyCommand(MenuController.nextSubTabCommand)
accountUpdater = mastodonController.persistentContainer.accountSubject
.filter { [weak self] in $0 == self?.accountID }
.receive(on: DispatchQueue.main)
.filter { [weak self] in $0 == self?.accountID }
.sink { [weak self] (_) in self?.updateAccountUI() }
loadAccount()
// disable the transparent nav bar because it gets messy with multiple pages at different scroll positions
if let nav = navigationController {
let appearance = UINavigationBarAppearance()
appearance.configureWithDefaultBackground()
nav.navigationBar.scrollEdgeAppearance = appearance
}
}
override func viewDidAppear(_ animated: Bool) {

View File

@ -11,7 +11,7 @@ import Pachyderm
typealias TimelineEntry = (id: String, state: StatusState)
class TimelineTableViewController: TimelineLikeTableViewController<TimelineEntry> {
class TimelineTableViewController: DiffableTimelineLikeTableViewController<TimelineTableViewController.Section, TimelineTableViewController.Item> {
let timeline: Timeline
weak var mastodonController: MastodonController!
@ -19,6 +19,9 @@ class TimelineTableViewController: TimelineLikeTableViewController<TimelineEntry
private var newer: RequestRange?
private var older: RequestRange?
private var didConfirmLoadMore = false
private var isShowingTimelineDescription = false
init(for timeline: Timeline, mastodonController: MastodonController) {
self.timeline = timeline
self.mastodonController = mastodonController
@ -40,113 +43,264 @@ class TimelineTableViewController: TimelineLikeTableViewController<TimelineEntry
}
deinit {
guard let persistentContainer = mastodonController?.persistentContainer else { return }
guard let persistentContainer = mastodonController?.persistentContainer,
let dataSource = dataSource else { return }
// decrement reference counts of any statuses we still have
// if the app is currently being quit, this will not affect the persisted data because
// the persistent container would already have been saved in SceneDelegate.sceneDidEnterBackground(_:)
for section in sections {
for (id, _) in section {
// todo: remove the whole reference count system
for case let .status(id: id, state: _) in dataSource.snapshot().itemIdentifiers {
persistentContainer.status(for: id)?.decrementReferenceCount()
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: "statusCell")
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
override class func refreshCommandTitle() -> String {
return NSLocalizedString("Refresh Statuses", comment: "refresh status command discoverability title")
}
override func willRemoveRows(at indexPaths: [IndexPath]) {
for indexPath in indexPaths {
let id = item(for: indexPath).id
mastodonController.persistentContainer.status(for: id)?.decrementReferenceCount()
override func timelineContentSections() -> [Section] {
return [.statuses]
}
override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? {
switch item {
case let .status(id: id, state: state):
let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell
cell.delegate = self
cell.updateUI(statusID: id, state: state)
return cell
case .confirmLoadMore:
let cell = tableView.dequeueReusableCell(withIdentifier: "confirmLoadMoreCell", for: indexPath) as! ConfirmLoadMoreTableViewCell
cell.confirmLoadMore = {
self.didConfirmLoadMore = true
self.loadOlder()
self.didConfirmLoadMore = false
}
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
}
}
override func loadInitialItems(completion: @escaping ([TimelineEntry]) -> Void) {
let request = Client.getStatuses(timeline: timeline)
mastodonController?.run(request) { (response) in
guard case let .success(statuses, pagination) = response else {
completion([])
override func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
guard let mastodonController = mastodonController else {
completion(.failure(.noClient))
return
}
let request = Client.getStatuses(timeline: timeline)
mastodonController.run(request) { response in
switch response {
case let .failure(error):
completion(.failure(.client(error)))
case let .success(statuses, pagination):
self.newer = pagination?.newer
self.older = pagination?.older
self.mastodonController?.persistentContainer.addAll(statuses: statuses) {
completion(statuses.map { ($0.id, .unknown) })
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
DispatchQueue.main.async {
var snapshot = self.dataSource.snapshot()
snapshot.deleteSections([.statuses, .footer])
snapshot.appendSections([.statuses, .footer])
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown) }, toSection: .statuses)
completion(.success(snapshot))
}
}
}
}
}
override func loadOlder(completion: @escaping ([TimelineEntry]) -> Void) {
override func loadOlderItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
guard let older = older else {
completion([])
completion(.failure(.noOlder))
return
}
if #available(iOS 15.0, *),
Preferences.shared.disableInfiniteScrolling && !didConfirmLoadMore {
var snapshot = currentSnapshot()
guard !snapshot.itemIdentifiers(inSection: .footer).contains(.confirmLoadMore) else {
// todo: need something more accurate than "success"/"failure"
completion(.success(snapshot))
return
}
snapshot.appendItems([.confirmLoadMore], toSection: .footer)
self.dataSource.apply(snapshot)
completion(.success(snapshot))
return
}
let request = Client.getStatuses(timeline: timeline, range: older)
mastodonController.run(request) { (response) in
guard case let .success(statuses, pagination) = response else {
completion([])
return
}
mastodonController.run(request) { response in
switch response {
case let .failure(error):
completion(.failure(.client(error)))
case let .success(statuses, pagination):
self.older = pagination?.older
self.mastodonController?.persistentContainer.addAll(statuses: statuses) {
completion(statuses.map { ($0.id, .unknown) })
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
var snapshot = currentSnapshot()
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown) }, toSection: .statuses)
snapshot.deleteItems([.confirmLoadMore])
completion(.success(snapshot))
}
}
}
}
override func loadNewer(completion: @escaping ([TimelineEntry]) -> Void) {
override func loadNewerItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
guard let newer = newer else {
completion([])
completion(.failure(.noNewer))
return
}
let request = Client.getStatuses(timeline: timeline, range: newer)
mastodonController.run(request) { response in
switch response {
case let .failure(error):
completion(.failure(.client(error)))
mastodonController.run(request) { (response) in
guard case let .success(statuses, pagination) = response else {
completion([])
case let .success(statuses, pagination):
guard !statuses.isEmpty else {
completion(.failure(.allCaughtUp))
return
}
// if there are no new statuses, pagination is nil
// if we were to then overwrite self.newer, future refreshes would fail
// if we were to then overwrite self.newer, future refresh would fail
if let newer = pagination?.newer {
self.newer = newer
}
self.mastodonController?.persistentContainer.addAll(statuses: statuses) {
completion(statuses.map { ($0.id, .unknown) })
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
var snapshot = currentSnapshot()
let newIdentifiers = statuses.map { Item.status(id: $0.id, state: .unknown) }
if let first = snapshot.itemIdentifiers(inSection: .statuses).first {
snapshot.insertItems(newIdentifiers, beforeItem: first)
} else {
snapshot.appendItems(newIdentifiers, toSection: .statuses)
}
completion(.success(snapshot))
}
}
}
}
// MARK: - UITableViewDataSource
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as? TimelineStatusTableViewCell else { fatalError() }
let (id, state) = item(for: indexPath)
cell.delegate = self
cell.updateUI(statusID: id, state: state)
return cell
override func willRemoveItems(_ items: [Item]) {
for case let .status(id: id, state: _) in items {
mastodonController.persistentContainer.status(for: id)?.decrementReferenceCount()
}
}
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 {
enum Section: Hashable, CaseIterable {
case header
case statuses
case footer
}
enum Item: Hashable {
case status(id: String, state: StatusState)
case confirmLoadMore
case publicTimelineDescription(local: Bool)
static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) {
case let (.status(id: a, state: _), .status(id: b, state: _)):
return a == b
case (.confirmLoadMore, .confirmLoadMore):
return true
case let (.publicTimelineDescription(local: a), .publicTimelineDescription(local: b)):
return a == b
default:
return false
}
}
func hash(into hasher: inout Hasher) {
switch self {
case let .status(id: id, state: _):
hasher.combine(0)
hasher.combine(id)
case .confirmLoadMore:
hasher.combine(1)
case let .publicTimelineDescription(local: local):
hasher.combine(2)
hasher.combine(local)
}
}
}
}
extension TimelineTableViewController: TuskerNavigationDelegate {
@ -161,17 +315,23 @@ extension TimelineTableViewController: StatusTableViewCellDelegate {
extension TimelineTableViewController: UITableViewDataSourcePrefetching, StatusTablePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
let ids = indexPaths.map { item(for: $0).id }
let ids: [String] = indexPaths.compactMap {
if case let .status(id: id, state: _) = dataSource.itemIdentifier(for: $0) {
return id
} else {
return nil
}
}
prefetchStatuses(with: ids)
}
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
let ids: [String] = indexPaths.compactMap {
guard $0.section < sections.count,
$0.row < sections[$0.section].count else {
if case let .status(id: id, state: _) = dataSource.itemIdentifier(for: $0) {
return id
} else {
return nil
}
return item(for: $0).id
}
cancelPrefetchingStatuses(with: ids)
}

View File

@ -0,0 +1,338 @@
//
// DiffableTimelineLikeTableViewController.swift
// Tusker
//
// Created by Shadowfacts on 6/18/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable, Item: Hashable>: EnhancedTableViewController, RefreshableViewController {
typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Item>
typealias LoadResult = Result<Snapshot, LoadError>
private let pageSize = 20
private(set) var state = State.unloaded
private var lastLastVisibleRow: IndexPath?
private(set) var dataSource: UITableViewDiffableDataSource<Section, Item>!
init() {
super.init(style: .plain)
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: Self.refreshCommandTitle()))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: self.cellProvider)
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 140
#if !targetEnvironment(macCatalyst)
self.refreshControl = UIRefreshControl()
self.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
#endif
if let prefetchSource = self as? UITableViewDataSourcePrefetching {
tableView.prefetchDataSource = prefetchSource
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
loadInitial()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
pruneOffscreenRows()
currentToast?.dismissToast(animated: false)
}
class func refreshCommandTitle() -> String {
return "Refresh"
}
private func pruneOffscreenRows() {
guard let lastVisibleRow = lastLastVisibleRow else {
return
}
var snapshot = dataSource.snapshot()
let lastVisibleRowSection = snapshot.sectionIdentifiers[lastVisibleRow.section]
let contentSections = snapshot.sectionIdentifiers.filter { timelineContentSections().contains($0) }
guard let lastVisibleContentSectionIndex = contentSections.lastIndex(of: lastVisibleRowSection) else {
return
}
if lastVisibleContentSectionIndex < contentSections.count - 1 {
// there are more content sections below the current last visible one
let sectionsToRemove = contentSections[lastVisibleContentSectionIndex...]
snapshot.deleteSections(Array(sectionsToRemove))
willRemoveItems(sectionsToRemove.flatMap(snapshot.itemIdentifiers(inSection:)))
} else if lastVisibleContentSectionIndex == contentSections.count - 1 {
let items = snapshot.itemIdentifiers(inSection: lastVisibleRowSection)
if lastVisibleRow.row < items.count - pageSize {
let itemsToRemove = Array(items.suffix(pageSize))
snapshot.deleteItems(itemsToRemove)
willRemoveItems(itemsToRemove)
} else {
return
}
} else {
return
}
dataSource.apply(snapshot, animatingDifferences: false)
}
private func loadInitial() {
guard state == .unloaded else { return }
// set loaded immediately so we don't trigger another request while the current one is running
state = .loadingInitial
loadInitialItems() { result in
DispatchQueue.main.async {
switch result {
case let .success(snapshot):
self.dataSource.apply(snapshot, animatingDifferences: false)
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
}
}
}
}
func reloadInitial() {
state = .unloaded
loadInitial()
}
func loadOlder() {
guard state != .loadingOlder else { return }
state = .loadingOlder
loadOlderItems(currentSnapshot: dataSource.snapshot) { result in
DispatchQueue.main.async {
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
}
}
}
}
func cellHeightChanged() {
// causes the table view to recalculate the cell heights
tableView.beginUpdates()
tableView.endUpdates()
}
// MARK: - UITableViewDelegate
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// this assumes that indexPathsForVisibleRows is always in order
lastLastVisibleRow = tableView.indexPathsForVisibleRows?.last
let orderedContentSections = dataSource.snapshot().sectionIdentifiers.enumerated().filter { timelineContentSections().contains($0.element) }
if let lastContentSection = orderedContentSections.last,
indexPath.section == lastContentSection.offset,
indexPath.row == tableView.numberOfRows(inSection: indexPath.section) - 1 {
loadOlder()
}
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
}
override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.leadingSwipeActionsConfiguration()
}
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
}
// MARK: - RefreshableViewController
func refresh() {
guard state != .loadingNewer else { return }
state = .loadingNewer
var firstItem: Item? = nil
let currentSnapshot: () -> Snapshot = {
let snapshot = self.dataSource.snapshot()
for section in self.timelineContentSections() {
if snapshot.indexOfSection(section) != nil,
let first = snapshot.itemIdentifiers(inSection: section).first {
firstItem = first
break
}
}
return snapshot
}
loadNewerItems(currentSnapshot: currentSnapshot) { result in
DispatchQueue.main.async {
self.refreshControl?.endRefreshing()
self.state = .loaded
switch result {
case let .success(snapshot):
self.dataSource.apply(snapshot, animatingDifferences: false)
if let firstItem = firstItem,
let indexPath = self.dataSource.indexPath(for: firstItem) {
// maintain the current position in the list (don't scroll to top)
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
}
}
}
}
// MARK: - Subclass Methods
func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? {
fatalError("cellProvider(_:_:_:) must be implemented by subclasses")
}
func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
fatalError("loadInitialItems(completion:) must be implemented by subclasses")
}
func loadOlderItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
fatalError("loadOlderItesm(completion:) must be implemented by subclasses")
}
func loadNewerItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
fatalError("loadNewerItems(completion:) must be implemented by subclasses")
}
func timelineContentSections() -> Section.AllCases {
return Section.allCases
}
func willRemoveItems(_ items: [Item]) {
}
}
extension DiffableTimelineLikeTableViewController {
enum State: Equatable {
case unloaded
case loadingInitial
case loaded
case loadingNewer
case loadingOlder
}
}
extension DiffableTimelineLikeTableViewController {
enum LoadError: LocalizedError {
case noClient
case noOlder
case noNewer
case allCaughtUp
case client(Client.Error)
}
}
extension DiffableTimelineLikeTableViewController: BackgroundableViewController {
func sceneDidEnterBackground() {
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

@ -37,10 +37,11 @@ extension MenuPreviewProvider {
guard let mastodonController = mastodonController,
let account = mastodonController.persistentContainer.account(for: accountID) else { return [] }
guard mastodonController.loggedIn else {
guard let loggedInAccountID = mastodonController.accountInfo?.id else {
// user is logged out
return [
openInSafariAction(url: account.url),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forAccount: accountID, sourceView: sourceView)
})
@ -63,13 +64,17 @@ extension MenuPreviewProvider {
let request = Client.getRelationships(accounts: [account.id])
// talk about callback hell :/
mastodonController.run(request) { [weak self] (response) in
if let self = self,
guard let self = self,
case let .success(results, _) = response,
let relationship = results.first {
let relationship = results.first else {
DispatchQueue.main.async {
elementHandler([])
}
return
}
let following = relationship.following
DispatchQueue.main.async {
elementHandler([
self.createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.plus", handler: { (_) in
let action = self.createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.plus", handler: { (_) in
let request = (following ? Account.unfollow : Account.follow)(accountID)
mastodonController.run(request) { (response) in
switch response {
@ -80,21 +85,24 @@ extension MenuPreviewProvider {
}
}
})
elementHandler([
action
])
}
}
}
}))
}
let shareSection = [
var shareSection = [
openInSafariAction(url: account.url),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forAccount: accountID, sourceView: sourceView)
})
]
addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showProfileActivity(id: accountID, accountID: loggedInAccountID))
return [
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection),
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: actionsSection),
@ -104,7 +112,7 @@ extension MenuPreviewProvider {
func actionsForURL(_ url: URL, sourceView: UIView?) -> [UIAction] {
return [
openInSafariAction(url: url),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forURL: url, sourceView: sourceView)
})
@ -133,13 +141,14 @@ extension MenuPreviewProvider {
]
}
func actionsForStatus(_ status: StatusMO, sourceView: UIView?) -> [UIMenuElement] {
func actionsForStatus(_ status: StatusMO, sourceView: UIView?, includeReply: Bool = true) -> [UIMenuElement] {
guard let mastodonController = mastodonController else { return [] }
guard mastodonController.loggedIn else {
guard let accountID = mastodonController.accountInfo?.id else {
// user is logged out
return [
openInSafariAction(url: status.url!),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forStatus: status.id, sourceView: sourceView)
})
@ -150,10 +159,6 @@ extension MenuPreviewProvider {
let muted = status.muted
var actionsSection = [
createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.compose(inReplyToID: status.id)
}),
createAction(identifier: "bookmark", title: bookmarked ? "Unbookmark" : "Bookmark", systemImageName: bookmarked ? "bookmark.fill" : "bookmark", handler: { [weak self] (_) in
guard let self = self else { return }
let request = (bookmarked ? Status.unbookmark : Status.bookmark)(status.id)
@ -174,9 +179,16 @@ extension MenuPreviewProvider {
})
]
if includeReply {
actionsSection.insert(createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.compose(inReplyToID: status.id)
}), at: 0)
}
if mastodonController.account != nil && mastodonController.account.id == status.account.id {
let pinned = status.pinned ?? false
actionsSection.append(createAction(identifier: "pin", title: pinned ? "Unpin" : "Pin", systemImageName: pinned ? "pin.slash" : "pin", handler: { [weak self] (_) in
actionsSection.append(createAction(identifier: "pin", title: pinned ? "Unpin from Profile" : "Pin to Profile", systemImageName: pinned ? "pin.slash" : "pin", handler: { [weak self] (_) in
guard let self = self else { return }
let request = (pinned ? Status.unpin : Status.pin)(status.id)
self.mastodonController?.run(request, completion: { [weak self] (response) in
@ -204,21 +216,13 @@ extension MenuPreviewProvider {
var shareSection = [
openInSafariAction(url: status.url!),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forStatus: status.id, sourceView: sourceView)
}),
]
#if targetEnvironment(macCatalyst)
shareSection.append(createAction(identifier: "new_window", title: "Open in New Window", systemImageName: "", handler: { (_) in
guard let id = mastodonController.accountInfo?.id else {
return
}
// todo: this should try to find an existing session
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: id), options: nil, errorHandler: nil)
}))
#endif
addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID))
return [
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection),
@ -242,6 +246,28 @@ extension MenuPreviewProvider {
})
}
private func addOpenInNewWindow(actions: inout [UIAction], activity: @escaping @autoclosure () -> NSUserActivity) {
#if SDK_IOS_15
if #available(iOS 15.0, *) {
let options = UIWindowScene.ActivationRequestOptions()
options.preferredPresentationStyle = .automatic
actions.append(UIWindowScene.ActivationAction { (_) in
return .init(userActivity: activity(), options: options, preview: nil)
})
} else if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
actions.append(createAction(identifier: "new_window", title: "Open in New Window", systemImageName: "rectangle.badge.plus", handler: { (_) in
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity(), options: nil, errorHandler: nil)
}))
}
#else
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
actions.append(createAction(identifier: "new_window", title: "Open in New Window", systemImageName: "rectangle.badge.plus", handler: { (_) in
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity(), options: nil, errorHandler: nil)
}))
}
#endif
}
}
extension LargeImageViewController: CustomPreviewPresenting {

View File

@ -44,6 +44,13 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
addKeyCommand(MenuController.prevSubTabCommand)
addKeyCommand(MenuController.nextSubTabCommand)
// disable the transparent nav bar because it gets messy with multiple pages at different scroll positions
if let nav = navigationController {
let appearance = UINavigationBarAppearance()
appearance.configureWithDefaultBackground()
nav.navigationBar.scrollEdgeAppearance = appearance
}
}
func selectPage(at index: Int, animated: Bool) {

View File

@ -9,8 +9,8 @@
import UIKit
/// A table view controller that manages common functionality between timeline-like UIs.
// For example, this class handles loading new items when the user scrolls to the end,
// refreshing, and pruning offscreen rows automatically.
/// For example, this class handles loading new items when the user scrolls to the end,
/// refreshing, and pruning offscreen rows automatically.
class TimelineLikeTableViewController<Item>: EnhancedTableViewController, RefreshableViewController {
private(set) var loaded = false

View File

@ -70,6 +70,7 @@ class UserActivityManager {
// TODO: check not currently showing compose screen
let mentioning = activity.userInfo?["mentioning"] as? String
let draft = mastodonController.createDraft(mentioningAcct: mentioning)
// todo: this shouldn't use self.mastodonController, it should get the right one based on the userInfo accountID
let composeVC = ComposeHostingController(draft: draft, mastodonController: mastodonController)
present(UINavigationController(rootViewController: composeVC))
}

View File

@ -89,15 +89,22 @@ extension TuskerNavigationDelegate {
}
func compose(editing draft: Draft) {
if #available(iOS 15.0, *), UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
let compose = UserActivityManager.editDraftActivity(id: draft.id, accountID: apiController.accountInfo!.id)
let options = UIWindowScene.ActivationRequestOptions()
options.preferredPresentationStyle = .prominent
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil)
} else {
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
let vc = UINavigationController(rootViewController: compose)
vc.presentationController?.delegate = compose
present(vc, animated: true)
let nav = UINavigationController(rootViewController: compose)
nav.presentationController?.delegate = compose
present(nav, animated: true)
}
}
func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil) {
let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct)
DraftsManager.shared.add(draft)
compose(editing: draft)
}

View File

@ -164,7 +164,9 @@ class AttachmentView: UIImageView, GIFAnimatable {
let attachmentURL = attachment.url
attachmentRequest = ImageCache.attachments.get(attachmentURL) { [weak self] (data, _) in
guard let self = self, let data = data else { return }
DispatchQueue.main.async {
self.attachmentRequest = nil
}
if self.attachment.url.pathExtension == "gif" {
self.source = .gifData(attachmentURL, data)
if self.autoplayGifs {

View File

@ -0,0 +1,55 @@
//
// ConfirmLoadMoreTableViewCell.swift
// Tusker
//
// Created by Shadowfacts on 6/23/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
protocol ConfirmLoadOlderTableViewCellDelegate: AnyObject {
func confirmLoadMore()
}
class ConfirmLoadMoreTableViewCell: UITableViewCell {
var confirmLoadMore: (() -> Void)?
@IBOutlet weak var confirmButton: UIButton!
private var isLoading = false
override func awakeFromNib() {
super.awakeFromNib()
if #available(iOS 15.0, *) {
var config = UIButton.Configuration.tinted()
config.title = "Load More"
config.showsActivityIndicator = false
config.imagePadding = 4
confirmButton.configuration = config
confirmButton.configurationUpdateHandler = { [unowned self] button in
button.configuration?.showsActivityIndicator = self.isLoading
}
}
}
override func prepareForReuse() {
super.prepareForReuse()
isLoading = false
if #available(iOS 15.0, *) {
confirmButton.setNeedsUpdateConfiguration()
}
}
@IBAction func loadMorePressed(_ sender: Any) {
confirmLoadMore?()
if #available(iOS 15.0, *) {
isLoading = true
confirmButton.setNeedsUpdateConfiguration()
}
}
}

View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19115.3" 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="19107.5"/>
<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="105" id="KGk-i7-Jjw" customClass="ConfirmLoadMoreTableViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="320" height="105"/>
<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="105"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="Rpx-45-c2n">
<rect key="frame" x="16" y="11" width="288" height="86"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Infinite scrolling is off. Do you want to keep going?" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="9nv-Re-5sL">
<rect key="frame" x="0.0" y="0.0" width="288" height="41"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="NT9-ly-efr">
<rect key="frame" x="0.0" y="45" width="288" height="41"/>
<state key="normal" title="Button"/>
<buttonConfiguration key="configuration" style="tinted" title="Load More"/>
<connections>
<action selector="loadMorePressed:" destination="KGk-i7-Jjw" eventType="touchUpInside" id="Pgz-MB-icB"/>
</connections>
</button>
</subviews>
</stackView>
</subviews>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
<constraints>
<constraint firstItem="Rpx-45-c2n" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="topMargin" id="YfZ-rr-Omf"/>
<constraint firstItem="Rpx-45-c2n" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leadingMargin" id="hhi-yX-Wa4"/>
<constraint firstAttribute="trailingMargin" secondItem="Rpx-45-c2n" secondAttribute="trailing" id="jI8-St-34M"/>
<constraint firstAttribute="bottom" secondItem="Rpx-45-c2n" secondAttribute="bottom" constant="8" id="mQh-0l-Eo2"/>
</constraints>
</tableViewCellContentView>
<viewLayoutGuide key="safeArea" id="njF-e1-oar"/>
<connections>
<outlet property="confirmButton" destination="NT9-ly-efr" id="Lja-th-LeH"/>
</connections>
<point key="canvasLocation" x="131.8840579710145" y="150.33482142857142"/>
</tableViewCell>
</objects>
<resources>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="secondarySystemBackgroundColor">
<color red="0.94901960784313721" green="0.94901960784313721" blue="0.96862745098039216" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View File

@ -143,9 +143,9 @@ class ContentTextView: LinkTextView {
case "del":
attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: attributed.fullRange)
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":
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"))
case "ol", "ul":
attributed.trimLeadingCharactersInSet(.whitespacesAndNewlines)
@ -157,7 +157,7 @@ class ContentTextView: LinkTextView {
if parentTag == "ol" {
let index = (try? node.elementSiblingIndex()) ?? 0
// 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" {
bullet = NSAttributedString(string: "\u{2022}\t")
} else {

View File

@ -33,6 +33,10 @@ class StatusPollView: UIView {
private var animator: UIViewPropertyAnimator!
private var currentSelectedOptionIndex: Int!
var isTracking: Bool {
optionsView.isTracking
}
override func awakeFromNib() {
super.awakeFromNib()

View File

@ -80,14 +80,14 @@ class ProfileHeaderView: UIView {
cancellables = []
mastodonController.persistentContainer.accountSubject
.filter { [weak self] in $0 == self?.accountID }
.receive(on: DispatchQueue.main)
.filter { [weak self] in $0 == self?.accountID }
.sink { [weak self] in self?.updateUI(for: $0) }
.store(in: &cancellables)
mastodonController.persistentContainer.relationshipSubject
.filter { [weak self] in $0 == self?.accountID }
.receive(on: DispatchQueue.main)
.filter { [weak self] in $0 == self?.accountID }
.sink { [weak self] (_) in self?.updateRelationship() }
.store(in: &cancellables)
}
@ -203,11 +203,14 @@ class ProfileHeaderView: UIView {
let image = image,
self.accountID == accountID,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else {
DispatchQueue.main.async {
self?.avatarRequest = nil
}
return
}
self.avatarRequest = nil
DispatchQueue.main.async {
self.avatarRequest = nil
self.avatarImageView.image = transformedImage
}
}
@ -217,11 +220,14 @@ class ProfileHeaderView: UIView {
let image = image,
self.accountID == accountID,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: header, image: image) else {
DispatchQueue.main.async {
self?.headerRequest = nil
}
return
}
self.headerRequest = nil
DispatchQueue.main.async {
self.headerRequest = nil
self.headerImageView.image = transformedImage
}
}

View File

@ -106,8 +106,8 @@ class BaseStatusTableViewCell: UITableViewCell, MenuPreviewProvider {
open func createObserversIfNecessary() {
if statusUpdater == nil {
statusUpdater = mastodonController.persistentContainer.statusSubject
.filter { [unowned self] in $0 == self.statusID }
.receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.statusID }
.sink { [unowned self] in
if let mastodonController = mastodonController,
let status = mastodonController.persistentContainer.status(for: $0) {
@ -118,8 +118,8 @@ class BaseStatusTableViewCell: UITableViewCell, MenuPreviewProvider {
if accountUpdater == nil {
accountUpdater = mastodonController.persistentContainer.accountSubject
.filter { [unowned self] in $0 == self.accountID }
.receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.accountID }
.sink { [unowned self] in
if let mastodonController = mastodonController,
let account = mastodonController.persistentContainer.account(for: $0) {
@ -210,7 +210,8 @@ class BaseStatusTableViewCell: UITableViewCell, MenuPreviewProvider {
}
// keep menu in sync with changed states e.g. bookmarked, muted
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: actionsForStatus(status, sourceView: moreButton))
// do not include reply action here, because the cell already contains a button for it
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: actionsForStatus(status, sourceView: moreButton, includeReply: false))
pollView.isHidden = status.poll == nil
pollView.mastodonController = mastodonController

View File

@ -59,8 +59,8 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
if rebloggerAccountUpdater == nil {
rebloggerAccountUpdater = mastodonController.persistentContainer.accountSubject
.filter { [unowned self] in $0 == self.rebloggerID }
.receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.rebloggerID }
.sink { [unowned self] in
if let mastodonController = self.mastodonController,
let reblogger = mastodonController.persistentContainer.account(for: $0) {
@ -326,8 +326,11 @@ extension TimelineStatusTableViewCell: TableViewSwipeActionProvider {
extension TimelineStatusTableViewCell: DraggableTableViewCell {
func dragItemsForBeginning(session: UIDragSession) -> [UIDragItem] {
// the poll options view is tracking while the user is dragging between options
// while that's happening, don't initiate a drag
guard let status = mastodonController.persistentContainer.status(for: statusID),
let accountID = mastodonController.accountInfo?.id else {
let accountID = mastodonController.accountInfo?.id,
!pollView.isTracking else {
return []
}
let provider = NSItemProvider(object: status.url! as NSURL)

View File

@ -1,8 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19115.2" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19107.4"/>
<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"/>
@ -38,7 +39,7 @@
</constraints>
</imageView>
<stackView opaque="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="751" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="gIY-Wp-RSk">
<rect key="frame" x="58" y="0.0" width="277" height="169.5"/>
<rect key="frame" x="58" y="0.0" width="277" height="173.5"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="3Sm-P0-ySf">
<rect key="frame" x="0.0" y="0.0" width="277" height="20.5"/>
@ -132,13 +133,19 @@
<rect key="frame" x="0.0" y="169.5" width="277" height="0.0"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="1" verticalCompressionResistancePriority="1" text="" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="oFl-rC-EEN">
<rect key="frame" x="0.0" y="173.5" width="277" height="0.0"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" distribution="equalSpacing" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="oie-wK-IpU">
<rect key="frame" x="0.0" y="54" width="50" height="22"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="bubble.left.and.bubble.right" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="KdQ-Zn-IhD">
<rect key="frame" x="0.0" y="1" width="25.5" height="21.5"/>
<rect key="frame" x="0.0" y="0.0" width="25.5" height="21.5"/>
<color key="tintColor" systemColor="secondaryLabelColor"/>
<accessibility key="accessibilityConfiguration" label="Is a reply"/>
<constraints>
@ -218,7 +225,7 @@
<constraint firstItem="gIY-Wp-RSk" firstAttribute="leading" secondItem="QMP-j2-HLn" secondAttribute="trailing" constant="8" id="0Tm-v7-Ts4"/>
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="QMP-j2-HLn" secondAttribute="bottom" constant="8" id="2Ao-Gj-fY3"/>
<constraint firstItem="TUP-Nz-5Yh" firstAttribute="trailing" secondItem="ve3-Y1-NQH" secondAttribute="trailingMargin" id="3l0-tE-Ak1"/>
<constraint firstItem="TUP-Nz-5Yh" firstAttribute="top" secondItem="gIY-Wp-RSk" secondAttribute="bottom" id="4KL-a3-qyf"/>
<constraint firstItem="TUP-Nz-5Yh" firstAttribute="top" secondItem="gIY-Wp-RSk" secondAttribute="bottom" constant="-4" id="4KL-a3-qyf"/>
<constraint firstItem="oie-wK-IpU" firstAttribute="top" secondItem="QMP-j2-HLn" secondAttribute="bottom" constant="4" id="7Mp-WS-FhY"/>
<constraint firstItem="TUP-Nz-5Yh" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="oie-wK-IpU" secondAttribute="bottom" id="7Xp-Sa-Rfk"/>
<constraint firstItem="QMP-j2-HLn" firstAttribute="top" secondItem="ve3-Y1-NQH" secondAttribute="top" id="PC4-Bi-QXm"/>
@ -276,7 +283,7 @@
<image name="ellipsis" catalog="system" width="128" height="37"/>
<image name="globe" catalog="system" width="128" height="121"/>
<image name="pin.fill" catalog="system" width="119" height="128"/>
<image name="repeat" catalog="system" width="128" height="99"/>
<image name="repeat" catalog="system" width="128" height="98"/>
<image name="star.fill" catalog="system" width="128" height="116"/>
<systemColor name="labelColor">
<color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>

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