Compare commits
63 Commits
ef1db466b9
...
e61823b78f
Author | SHA1 | Date |
---|---|---|
Shadowfacts | e61823b78f | |
Shadowfacts | 4d52ac4d34 | |
Shadowfacts | aced0a63c9 | |
Shadowfacts | 1e54235ff5 | |
Shadowfacts | e6e5554edf | |
Shadowfacts | 9026f487ec | |
Shadowfacts | c0097ba752 | |
Shadowfacts | f109253bba | |
Shadowfacts | 1fda4248ec | |
Shadowfacts | 7781c5252b | |
Shadowfacts | 7f4bf52050 | |
Shadowfacts | ba0d179de5 | |
Shadowfacts | 71b6f1bdf0 | |
Shadowfacts | 09ec4a920c | |
Shadowfacts | 7edf0fdb93 | |
Shadowfacts | 99e06441f0 | |
Shadowfacts | 85e1e131f6 | |
Shadowfacts | 1d79918a94 | |
Shadowfacts | 340d13b1fa | |
Shadowfacts | cf1000a4df | |
Shadowfacts | b781b56efd | |
Shadowfacts | 10a8a85bfc | |
Shadowfacts | 6d8a014cc7 | |
Shadowfacts | 60c88ded5e | |
Shadowfacts | 1e7a6af0bf | |
Shadowfacts | f8b79ef34f | |
Shadowfacts | 4cf56685b5 | |
Shadowfacts | fdcd2aa540 | |
Shadowfacts | 667d30a710 | |
Shadowfacts | b0f23e46ba | |
Shadowfacts | 9b30b48016 | |
Shadowfacts | bd49683e13 | |
Shadowfacts | c22945b1e7 | |
Shadowfacts | 0a16a2e261 | |
Shadowfacts | b95819cada | |
Shadowfacts | dc1ea1bed9 | |
Shadowfacts | 5f9fe505d5 | |
Shadowfacts | 5b8e97287e | |
Shadowfacts | 49572c1fec | |
Shadowfacts | ebb0770198 | |
Shadowfacts | 27e05cc72d | |
Shadowfacts | 4ca48a5f50 | |
Shadowfacts | 230bd50661 | |
Shadowfacts | 4f2f8d517f | |
Shadowfacts | 130da9d4cc | |
Shadowfacts | 472b9aa5e2 | |
Shadowfacts | 3413dff8f9 | |
Shadowfacts | 66e8fce488 | |
Shadowfacts | aa2d333f4a | |
Shadowfacts | c8a45d8eef | |
Shadowfacts | 40f5be28f6 | |
Shadowfacts | 7c9287543c | |
Shadowfacts | 2a05b6d326 | |
Shadowfacts | 2499d25432 | |
Shadowfacts | 9417872790 | |
Shadowfacts | c02a1bbf74 | |
Shadowfacts | 0a894b219a | |
Shadowfacts | 22803668d2 | |
Shadowfacts | 2f6d1cb069 | |
Shadowfacts | 8889261b6b | |
Shadowfacts | 91f1a5195c | |
Shadowfacts | 1a5b958b1a | |
Shadowfacts | d667f6362c |
60
CHANGELOG.md
60
CHANGELOG.md
|
@ -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.
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -88,6 +88,10 @@
|
|||
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
|
||||
isEnabled = "YES">
|
||||
</CommandLineArgument>
|
||||
<CommandLineArgument
|
||||
argument = "-UIFocusLoopDebuggerEnabled YES"
|
||||
isEnabled = "YES">
|
||||
</CommandLineArgument>
|
||||
</CommandLineArguments>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"object": {
|
||||
"pins": [
|
||||
{
|
||||
"package": "PLCrashReporter",
|
||||
"package": "plcrashreporter",
|
||||
"repositoryURL": "https://github.com/microsoft/plcrashreporter",
|
||||
"state": {
|
||||
"branch": null,
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -58,7 +58,7 @@ class ListTimelineViewController: TimelineTableViewController {
|
|||
dismiss(animated: true)
|
||||
|
||||
// todo: show loading indicator
|
||||
reloadInitialItems()
|
||||
reloadInitial()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -89,3 +89,11 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController {
|
|||
root.performSearch(query: query)
|
||||
}
|
||||
}
|
||||
|
||||
extension AccountSwitchingContainerViewController: BackgroundableViewController {
|
||||
func sceneDidEnterBackground() {
|
||||
if let backgroundable = root as? BackgroundableViewController {
|
||||
backgroundable.sceneDidEnterBackground()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"/>
|
||||
|
|
|
@ -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?()
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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, ¤tToastKey) as? WeakHolder<ToastView>
|
||||
return holder?.object
|
||||
}
|
||||
set {
|
||||
if let newValue = newValue {
|
||||
objc_setAssociatedObject(self, ¤tToastKey, WeakHolder(object: newValue), .OBJC_ASSOCIATION_RETAIN)
|
||||
} else {
|
||||
objc_setAssociatedObject(self, ¤tToastKey, 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
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue