Compare commits
127 Commits
8fc915d6a0
...
ee630cf9df
Author | SHA1 | Date |
---|---|---|
Shadowfacts | ee630cf9df | |
Shadowfacts | c786c022b8 | |
Shadowfacts | 33649cc5c0 | |
Shadowfacts | 71a10f8514 | |
Shadowfacts | a864f4e344 | |
Shadowfacts | 007d5d6791 | |
Shadowfacts | f176a6c8eb | |
Shadowfacts | 104981f3d3 | |
Shadowfacts | 2ba6b64485 | |
Shadowfacts | 81ac3708a3 | |
Shadowfacts | 8e9e0fa346 | |
Shadowfacts | b6f32ca6be | |
Shadowfacts | e042754be1 | |
Shadowfacts | 38ac5858a9 | |
Shadowfacts | 0c0180264e | |
Shadowfacts | 3d9477f0c9 | |
Shadowfacts | 6f51f321f6 | |
Shadowfacts | ab17a688cf | |
Shadowfacts | 18bc6ce61e | |
Shadowfacts | 765b5e1a7c | |
Shadowfacts | a3e64703ab | |
Shadowfacts | d74be9d81d | |
Shadowfacts | 6ca5bb0c74 | |
Shadowfacts | 76550d8fb8 | |
Shadowfacts | daf3741c9a | |
Shadowfacts | b2977540e0 | |
Shadowfacts | bcc70e9f8c | |
Shadowfacts | 2252b6d09e | |
Shadowfacts | 8deb502140 | |
Shadowfacts | 2582907919 | |
Shadowfacts | 266868376d | |
Shadowfacts | 71fa3910a1 | |
Shadowfacts | 75f290ae8f | |
Shadowfacts | 073a1afbde | |
Shadowfacts | aaa031f212 | |
Shadowfacts | 762d298c06 | |
Shadowfacts | 2a892fa6ec | |
Shadowfacts | cb82826fcf | |
Shadowfacts | 6e5498430f | |
Shadowfacts | 57fb921573 | |
Shadowfacts | d1b5126288 | |
Shadowfacts | 9d2324b587 | |
Shadowfacts | 60921cb95f | |
Shadowfacts | 9e76879ce6 | |
Shadowfacts | 1992a4c60b | |
Shadowfacts | f833bc3a6f | |
Shadowfacts | 4731801893 | |
Shadowfacts | 4293b51c31 | |
Shadowfacts | ecadb83c6d | |
Shadowfacts | 205bdffebd | |
Shadowfacts | ae7ca9c91c | |
Shadowfacts | 841119949b | |
Shadowfacts | b63f663947 | |
Shadowfacts | 00a23b525f | |
Shadowfacts | ea85b11945 | |
Shadowfacts | d8c7eb5cf5 | |
Shadowfacts | 8bc185ecf9 | |
Shadowfacts | 1832e64ad7 | |
Shadowfacts | 87bc1f5f75 | |
Shadowfacts | 6e2f6bb8e9 | |
Shadowfacts | 74d8adfffe | |
Shadowfacts | 99127b617b | |
Shadowfacts | 65ea72c07f | |
Shadowfacts | 04ca932a01 | |
Shadowfacts | 4ea2dff8f1 | |
Shadowfacts | 9f0176350c | |
Shadowfacts | dac1e1fe3f | |
Shadowfacts | afed69e43e | |
Shadowfacts | b2096f22c3 | |
Shadowfacts | 14c456df22 | |
Shadowfacts | 3f34357692 | |
Shadowfacts | 429dcefa88 | |
Shadowfacts | d1a35620c9 | |
Shadowfacts | ce741d6e1f | |
Shadowfacts | 5a82851fe9 | |
Shadowfacts | 92ff900bc0 | |
Shadowfacts | 2a1deb8d7d | |
Shadowfacts | 38eea44a8b | |
Shadowfacts | 2d45fbbd91 | |
Shadowfacts | 32382c4783 | |
Shadowfacts | 521c46c0be | |
Shadowfacts | c114749519 | |
Shadowfacts | 825424cfba | |
Shadowfacts | 985eb24e88 | |
Shadowfacts | 7cadcf1e86 | |
Shadowfacts | a314521b96 | |
Shadowfacts | ab3bad0e16 | |
Shadowfacts | ec75906bc1 | |
Shadowfacts | 137a537f68 | |
Shadowfacts | 91123fd24a | |
Shadowfacts | 597dd56032 | |
Shadowfacts | 37847a2f9f | |
Shadowfacts | 471d3459a6 | |
Shadowfacts | 512eec09a8 | |
Shadowfacts | af8a9faaeb | |
Shadowfacts | 20c4c4bb2f | |
Shadowfacts | 76268e7a14 | |
Shadowfacts | 29596180a1 | |
Shadowfacts | ebfd8b3efd | |
Shadowfacts | 509acbde19 | |
Shadowfacts | 474064669d | |
Shadowfacts | 1940368c43 | |
Shadowfacts | 49c9c69b5a | |
Shadowfacts | ff29f2768b | |
Shadowfacts | 942df433b3 | |
Shadowfacts | 5e2b551045 | |
Shadowfacts | 2e64500c35 | |
Shadowfacts | 7b7c05ff68 | |
Shadowfacts | aec5c0b787 | |
Shadowfacts | d8901b38f5 | |
Shadowfacts | 9d7c876e3c | |
Shadowfacts | 455273f322 | |
Shadowfacts | 16347b2ad0 | |
Shadowfacts | 0e1cbce10d | |
Shadowfacts | 8bd6f53f01 | |
Shadowfacts | fe32356bce | |
Shadowfacts | 1f337613be | |
Shadowfacts | 3f4a62f5f9 | |
Shadowfacts | b506704716 | |
Shadowfacts | 6a3dcca9ee | |
Shadowfacts | edd1e55cbb | |
Shadowfacts | f1facea929 | |
Shadowfacts | d638ea054b | |
Shadowfacts | e11784904b | |
Shadowfacts | 9f1d3804d9 | |
Shadowfacts | 333295367a | |
Shadowfacts | e9d14c6cbf |
|
@ -0,0 +1,47 @@
|
|||
## 2023.4
|
||||
Features/Improvements:
|
||||
- Add preference for non-pure-black dark mode
|
||||
- Add Jump to Present button to timelines on the home tab
|
||||
- Consolidate Trends into a single screen
|
||||
- Allow pinning instance public timelines to the Home tab
|
||||
- Add GIF/ALT badges to attachments (and preference to hide them)
|
||||
- Add action to show hide/show reblogs from specific accounts
|
||||
- Add preference to hide link preview cards
|
||||
- Hide placeholder image in link preview card for previews without images
|
||||
- Truncate links in posts
|
||||
- Move Drafts button in Compose screen to nav bar to reduce accidental presses
|
||||
- Load more posts/notifications on each page
|
||||
- Update Bookmarks screen when posts are bookmarked/unbookmarked
|
||||
- Add infinite scrolling to Bookmarks screen
|
||||
- Add Favorites screen to the Explore tab
|
||||
- Make attachment description text selectable in gallery
|
||||
- Add long press to copy username on profile screens
|
||||
- Optimize conversation loading
|
||||
- Apply server-configured poll limits in Compose screen
|
||||
- Add infinite scrolling to trending links/hashtags/posts
|
||||
- Add state restoration for more screens
|
||||
- Persist state when switching between accounts
|
||||
- Add Handoff support for various screens
|
||||
- Add preference to sync timeline position using Mastodon API, rather than iCloud
|
||||
- Show percentage of voters for multi-choice polls, rather than percentage of votes
|
||||
- Display message on remote profiles with no posts
|
||||
- Indicate moved profiles
|
||||
- Make Load More button on timelines more prominent
|
||||
- VoiceOver: Make fast account switcher accessible
|
||||
- VoiceOver: Improve labels for notifications
|
||||
- VoiceOver: Fix custom emoji picker not having labels
|
||||
|
||||
Bugfixes:
|
||||
- Workaround for not being able to sign in to certain instances
|
||||
- Fix timeline position sync not working in certain circumstances
|
||||
- Fix local-only posts not being decodable when logged in to Akkoma instances
|
||||
- Fix Trends sometimes appearing in Explore/sidebar on non-Mastodon instances
|
||||
- Fix favoriters/rebloggers list not resizing on screen rotation
|
||||
- Fix crash when tapping My Profile tab immediately after app launch
|
||||
- Handle authentication required errors on instance public timelines
|
||||
- Fix follow request accept/reject buttons not matching accent color preference
|
||||
- Fix tapping reblog count in conversation main status showing favorites list
|
||||
- Fix crash when certain tags are present in post HTML
|
||||
- Fix crash when opening Report screen in certain circumstances
|
||||
- iPadOS: Fix crash when resizing window while on the Explore screen
|
||||
- iOS 15: Fix accent colors not being displayed in Preferences
|
92
CHANGELOG.md
92
CHANGELOG.md
|
@ -1,5 +1,97 @@
|
|||
# Changelog
|
||||
|
||||
## 2023.4 (75)
|
||||
This build contains tweaks to automatic error reporting for the timeline marker API. The previous build's changelog is included below.
|
||||
|
||||
## 2023.4 (74)
|
||||
Features/Improvements:
|
||||
- Add state restoration for more screens
|
||||
- Persist state when switching between accounts
|
||||
- Add handoff for various screens
|
||||
- Add preference to hide GIF/ALT badges on attachments
|
||||
- Add preference to use Mastodon timeline marker API for syncing Home timeline position
|
||||
- Show percentage of voters for multi-choice poll results, rather than percentage of votes
|
||||
- Change search results view controller to dismiss keyboard on scroll
|
||||
- Only show inaccurate favorite/reblog count warning for posts from remote instances
|
||||
- Show message on remote profiles with no statuses
|
||||
- Add banner to profiles that have moved
|
||||
- Hide placeholder image for link cards without images
|
||||
- Don't check for present statuses when refreshing timeline
|
||||
- Make timeline Load More button more prominent
|
||||
- iOS 16.4: Use iOS-provided link previews in Share Sheet
|
||||
|
||||
Bugfixes:
|
||||
- Fix tapping reblog count in conversation main status showing favorites list
|
||||
- Fix status favorite/reblog list not adjusting to non-pure-black dark mode
|
||||
- Fix non-pure-black dark mode not applying to auxiliary windows
|
||||
- Fix poll option tracking gesture unselecting options when touch location moves between options
|
||||
- Fix crash when tapping conversation "More Replies" cell
|
||||
- Fix crash when script/style tags are present in post HTML
|
||||
- Fix crash when opening Report screen in certain circumstances
|
||||
|
||||
## 2023.4 (73)
|
||||
Features/Improvements:
|
||||
- Add preference for non-pure-black dark mode
|
||||
- Add Jump to Present button to timelines
|
||||
- Improve status collapse animation in search results screen
|
||||
- Add more trending links/hashtags/profiles buttons to Trends screen
|
||||
- Add infinite scrolling to trending links/hashtags screens
|
||||
- Add Share action to trending link context menu
|
||||
|
||||
Bugfixes:
|
||||
- Fix icon in suggested profile popover not adjusting to dark mode
|
||||
|
||||
## 2023.4 (72)
|
||||
Features/Improvements:
|
||||
- Consolidate Trends into a single screen
|
||||
- Make attachment description text selectable in gallery
|
||||
- Add long press to copy usernames on profile screen
|
||||
- Add Favorites screen to Explore tab
|
||||
- Optimize conversation loading when opening a conversation that is already fully-loaded
|
||||
- Apply Mastodon poll limits in Compose screen
|
||||
- VoiceOver: Fast account switcher improvements (make the screen modal, select the first account upon opening the switcher, make each account a single item)
|
||||
- VoiceOver: Improve labels for notifications
|
||||
- VoiceOver: Fix custom emoji picker buttons not having labels
|
||||
|
||||
Bugfixes:
|
||||
- Fix trends sometimes appearing in Explore/sidebar on non-Mastodon instances
|
||||
- Fix status favorite/reblog accounts list not resizing on device rotation
|
||||
- Fix bookmarks screen sometimes going haywire
|
||||
- Fix trending statuses not being deselected upon navigating back
|
||||
- Fix crash when tapping My Profile tab too early in app lifecycle
|
||||
- Handle 401 errors on instance timelines properly
|
||||
- Fix potential crash when showing context menu previews for status
|
||||
- Fix follow request accept/reject buttons not matching accent color preference
|
||||
- iPadOS: Fix crash when switching between sidebar and tab bar while on the Explore screen
|
||||
- iOS 15: Fix accent colors not being disaplyed in Preferences
|
||||
|
||||
## 2023.4 (71)
|
||||
Features/Improvements:
|
||||
- Allow pinning instance public timelines to the Home tab
|
||||
- Improve UI and retry mechanism when adding account
|
||||
- Increase page size to 40 on a bunch of screens
|
||||
- Update bookmarks screen when posts are bookmarked/unbookmarked
|
||||
- Allow loading older and refreshing bookmarks screen
|
||||
- Tweak follow count button color
|
||||
|
||||
Bugfixes:
|
||||
- Fix timeline position sync not working in certain circumstances
|
||||
- iPadOS: Fix flicker when opening favorite/reblog list in notificationss
|
||||
|
||||
## 2023.4 (70)
|
||||
Features/Improvements:
|
||||
- Add GIF/ALT badges to attachments
|
||||
- Add menu action to hide/show reblogs from specific accounts
|
||||
- Apply Mastodon's link truncation
|
||||
- Add preference to hide link preview cards
|
||||
- Tweak link preview card border color in dark mode
|
||||
- Unify haptic feedback across the app
|
||||
- Move Drafts button to the nav bar when the post doesn't have any content, to reduce accidental presses
|
||||
|
||||
Bugfixes:
|
||||
- Fix status URLs with fragments not being resolved
|
||||
- Workaround for local-only posts not being decodable when logged in to Akkoma instances
|
||||
|
||||
## 2023.3 (69)
|
||||
Features/Improvements:
|
||||
- Add Tip Jar under Preferences
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
# Haptic Feedback
|
||||
|
||||
## Selection changed
|
||||
`UISelectionFeedbackGenerator`
|
||||
|
||||
## Actions
|
||||
On success, `UIImpactFeedbackGenerator` with the `.light` style. On error, `UINotificationFeedbackGenerator` with the `.error` type.
|
|
@ -62,7 +62,7 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
|
|||
guard case .idle = state else {
|
||||
if animated,
|
||||
case .ducked(_, placeholder: let placeholder) = state {
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
let origConstant = placeholder.topConstraint.constant
|
||||
UIView.animateKeyframes(withDuration: 0.4, delay: 0) {
|
||||
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
|
||||
|
|
|
@ -155,6 +155,27 @@ public class Client {
|
|||
}
|
||||
}
|
||||
|
||||
public func revokeAccessToken() async throws {
|
||||
guard let accessToken else {
|
||||
return
|
||||
}
|
||||
let request = Request<Empty>(method: .post, path: "/oauth/revoke", body: ParametersBody([
|
||||
"token" => accessToken,
|
||||
"client_id" => clientID!,
|
||||
"client_secret" => clientSecret!,
|
||||
]))
|
||||
return try await withCheckedThrowingContinuation({ continuation in
|
||||
self.run(request) { response in
|
||||
switch response {
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
case .success(_, _):
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public func nodeInfo(completion: @escaping Callback<NodeInfo>) {
|
||||
let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo")
|
||||
run(wellKnown) { result in
|
||||
|
@ -178,8 +199,10 @@ public class Client {
|
|||
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
|
||||
}
|
||||
|
||||
public static func getFavourites() -> Request<[Status]> {
|
||||
return Request<[Status]>(method: .get, path: "/api/v1/favourites")
|
||||
public static func getFavourites(range: RequestRange = .default) -> Request<[Status]> {
|
||||
var request = Request<[Status]>(method: .get, path: "/api/v1/favourites")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
public static func getRelationships(accounts: [String]? = nil) -> Request<[Relationship]> {
|
||||
|
@ -394,32 +417,46 @@ public class Client {
|
|||
}
|
||||
|
||||
// MARK: - Instance
|
||||
public static func getTrendingHashtags(limit: Int? = nil) -> Request<[Hashtag]> {
|
||||
let parameters: [Parameter]
|
||||
if let limit = limit {
|
||||
parameters = ["limit" => limit]
|
||||
} else {
|
||||
parameters = []
|
||||
public static func getTrendingHashtagsDeprecated(limit: Int? = nil, offset: Int? = nil) -> Request<[Hashtag]> {
|
||||
var parameters: [Parameter] = []
|
||||
if let limit {
|
||||
parameters.append("limit" => limit)
|
||||
}
|
||||
if let offset {
|
||||
parameters.append("offset" => offset)
|
||||
}
|
||||
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends", queryParameters: parameters)
|
||||
}
|
||||
|
||||
public static func getTrendingStatuses(limit: Int? = nil) -> Request<[Status]> {
|
||||
let parameters: [Parameter]
|
||||
if let limit = limit {
|
||||
parameters = ["limit" => limit]
|
||||
} else {
|
||||
parameters = []
|
||||
public static func getTrendingHashtags(limit: Int? = nil, offset: Int? = nil) -> Request<[Hashtag]> {
|
||||
var parameters: [Parameter] = []
|
||||
if let limit {
|
||||
parameters.append("limit" => limit)
|
||||
}
|
||||
if let offset {
|
||||
parameters.append("offset" => offset)
|
||||
}
|
||||
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters)
|
||||
}
|
||||
|
||||
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[Status]> {
|
||||
var parameters: [Parameter] = []
|
||||
if let limit {
|
||||
parameters.append("limit" => limit)
|
||||
}
|
||||
if let offset {
|
||||
parameters.append("offset" => offset)
|
||||
}
|
||||
return Request(method: .get, path: "/api/v1/trends/statuses", queryParameters: parameters)
|
||||
}
|
||||
|
||||
public static func getTrendingLinks(limit: Int? = nil) -> Request<[Card]> {
|
||||
let parameters: [Parameter]
|
||||
if let limit = limit {
|
||||
parameters = ["limit" => limit]
|
||||
} else {
|
||||
parameters = []
|
||||
public static func getTrendingLinks(limit: Int? = nil, offset: Int? = nil) -> Request<[Card]> {
|
||||
var parameters: [Parameter] = []
|
||||
if let limit {
|
||||
parameters.append("limit" => limit)
|
||||
}
|
||||
if let offset {
|
||||
parameters.append("offset" => offset)
|
||||
}
|
||||
return Request(method: .get, path: "/api/v1/trends/links", queryParameters: parameters)
|
||||
}
|
||||
|
@ -447,7 +484,7 @@ public class Client {
|
|||
}
|
||||
|
||||
extension Client {
|
||||
public struct Error: LocalizedError {
|
||||
public struct Error: LocalizedError, Sendable {
|
||||
public let requestMethod: Method
|
||||
public let requestEndpoint: Endpoint
|
||||
public let type: ErrorType
|
||||
|
@ -482,7 +519,7 @@ extension Client {
|
|||
}
|
||||
}
|
||||
}
|
||||
public enum ErrorType: LocalizedError {
|
||||
public enum ErrorType: LocalizedError, Sendable {
|
||||
case networkError(Swift.Error)
|
||||
case unexpectedStatus(Int)
|
||||
case invalidRequest
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public final class Account: AccountProtocol, Decodable {
|
||||
public final class Account: AccountProtocol, Decodable, Sendable {
|
||||
public let id: String
|
||||
public let username: String
|
||||
public let acct: String
|
||||
|
@ -25,7 +25,7 @@ public final class Account: AccountProtocol, Decodable {
|
|||
public let avatarStatic: URL?
|
||||
public let header: URL?
|
||||
public let headerStatic: URL?
|
||||
public private(set) var emojis: [Emoji]
|
||||
public let emojis: [Emoji]
|
||||
public let moved: Bool?
|
||||
public let movedTo: Account?
|
||||
public let fields: [Field]
|
||||
|
@ -109,6 +109,12 @@ public final class Account: AccountProtocol, Decodable {
|
|||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/follow")
|
||||
}
|
||||
|
||||
public static func setShowReblogs(_ accountID: String, showReblogs: Bool) -> Request<Relationship> {
|
||||
return Request(method: .post, path: "/api/v1/accounts/\(accountID)/follow", body: ParametersBody([
|
||||
"reblogs" => showReblogs
|
||||
]))
|
||||
}
|
||||
|
||||
public static func unfollow(_ accountID: String) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unfollow")
|
||||
}
|
||||
|
@ -165,7 +171,7 @@ extension Account: CustomDebugStringConvertible {
|
|||
}
|
||||
|
||||
extension Account {
|
||||
public struct Field: Codable {
|
||||
public struct Field: Codable, Equatable, Sendable {
|
||||
public let name: String
|
||||
public let value: String
|
||||
public let verifiedAt: Date?
|
||||
|
|
|
@ -8,11 +8,11 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class Application: Decodable {
|
||||
public struct Application: Decodable, Sendable {
|
||||
public let name: String
|
||||
public let website: URL?
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.name = try container.decode(String.self, forKey: .name)
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class Attachment: Codable {
|
||||
public struct Attachment: Codable, Sendable {
|
||||
public let id: String
|
||||
public let kind: Kind
|
||||
public let url: URL
|
||||
|
@ -25,7 +25,7 @@ public class Attachment: Codable {
|
|||
], nil))
|
||||
}
|
||||
|
||||
required public init(from decoder: Decoder) throws {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.id = try container.decode(String.self, forKey: .id)
|
||||
self.kind = try container.decode(Kind.self, forKey: .kind)
|
||||
|
@ -50,7 +50,7 @@ public class Attachment: Codable {
|
|||
}
|
||||
|
||||
extension Attachment {
|
||||
public enum Kind: String, Codable {
|
||||
public enum Kind: String, Codable, Sendable {
|
||||
case image
|
||||
case video
|
||||
case gifv
|
||||
|
@ -77,7 +77,7 @@ extension Attachment {
|
|||
}
|
||||
|
||||
extension Attachment {
|
||||
public struct Metadata: Codable {
|
||||
public struct Metadata: Codable, Sendable {
|
||||
public let length: String?
|
||||
public let duration: Float?
|
||||
public let audioEncoding: String?
|
||||
|
@ -108,7 +108,7 @@ extension Attachment {
|
|||
}
|
||||
}
|
||||
|
||||
public struct ImageMetadata: Codable {
|
||||
public struct ImageMetadata: Codable, Sendable {
|
||||
public let width: Int?
|
||||
public let height: Int?
|
||||
public let size: String?
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public class Card: Codable {
|
||||
public struct Card: Codable, Sendable {
|
||||
public let url: WebURL
|
||||
public let title: String
|
||||
public let description: String
|
||||
|
@ -26,7 +26,7 @@ public class Card: Codable {
|
|||
/// Only present when returned from the trending links endpoint
|
||||
public let history: [History]?
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.url = try container.decode(WebURL.self, forKey: .url)
|
||||
|
@ -75,7 +75,7 @@ public class Card: Codable {
|
|||
}
|
||||
|
||||
extension Card {
|
||||
public enum Kind: String, Codable {
|
||||
public enum Kind: String, Codable, Sendable {
|
||||
case link
|
||||
case photo
|
||||
case video
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class ConversationContext: Decodable {
|
||||
public struct ConversationContext: Decodable, Sendable {
|
||||
public let ancestors: [Status]
|
||||
public let descendants: [Status]
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public enum DirectoryOrder: String, CaseIterable {
|
||||
public enum DirectoryOrder: String, CaseIterable, Sendable {
|
||||
case active
|
||||
case new
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public class Emoji: Codable {
|
||||
public struct Emoji: Codable, Sendable {
|
||||
public let shortcode: String
|
||||
// these shouldn't need to be WebURLs as they're not external resources,
|
||||
// but some instances (pleroma?) has emoji urls that Foundation considers malformed so we use WebURL to be more lenient
|
||||
|
@ -18,7 +18,7 @@ public class Emoji: Codable {
|
|||
public let visibleInPicker: Bool
|
||||
public let category: String?
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.shortcode = try container.decode(String.self, forKey: .shortcode)
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct FilterV1: Decodable {
|
||||
public struct FilterV1: Decodable, Sendable {
|
||||
public let id: String
|
||||
public let phrase: String
|
||||
private let context: [String]
|
||||
|
@ -45,7 +45,7 @@ public struct FilterV1: Decodable {
|
|||
}
|
||||
|
||||
extension FilterV1 {
|
||||
public enum Context: String, Decodable, CaseIterable {
|
||||
public enum Context: String, Decodable, CaseIterable, Sendable {
|
||||
case home
|
||||
case notifications
|
||||
case `public`
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct FilterV2: Decodable {
|
||||
public struct FilterV2: Decodable, Sendable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let context: [FilterV1.Context]
|
||||
|
@ -80,14 +80,14 @@ public struct FilterV2: Decodable {
|
|||
}
|
||||
|
||||
extension FilterV2 {
|
||||
public enum Action: String, Decodable, Hashable, CaseIterable {
|
||||
public enum Action: String, Decodable, Hashable, CaseIterable, Sendable {
|
||||
case warn
|
||||
case hide
|
||||
}
|
||||
}
|
||||
|
||||
extension FilterV2 {
|
||||
public struct Keyword: Decodable {
|
||||
public struct Keyword: Decodable, Sendable {
|
||||
public let id: String
|
||||
public let keyword: String
|
||||
public let wholeWord: Bool
|
||||
|
|
|
@ -10,7 +10,7 @@ import Foundation
|
|||
import WebURL
|
||||
import WebURLFoundationExtras
|
||||
|
||||
public class Hashtag: Codable {
|
||||
public struct Hashtag: Codable, Sendable {
|
||||
public let name: String
|
||||
public let url: WebURL
|
||||
/// Only present when returned from the trending hashtags endpoint
|
||||
|
@ -25,7 +25,7 @@ public class Hashtag: Codable {
|
|||
self.following = nil
|
||||
}
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.name = try container.decode(String.self, forKey: .name)
|
||||
// pixelfed (possibly others) don't fully escape special characters in the hashtag url
|
||||
|
|
|
@ -8,12 +8,12 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class History: Codable {
|
||||
public struct History: Codable, Sendable {
|
||||
public let day: Date
|
||||
public let uses: Int
|
||||
public let accounts: Int
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
if let day = try? container.decode(Date.self, forKey: .day) {
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class Instance: Decodable {
|
||||
public struct Instance: Decodable, Sendable {
|
||||
public let uri: String
|
||||
public let title: String
|
||||
public let description: String
|
||||
|
@ -37,7 +37,7 @@ public class Instance: Decodable {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.uri = try container.decode(String.self, forKey: .uri)
|
||||
self.title = try container.decode(String.self, forKey: .title)
|
||||
|
@ -93,7 +93,7 @@ public class Instance: Decodable {
|
|||
}
|
||||
|
||||
extension Instance {
|
||||
public struct Stats: Decodable {
|
||||
public struct Stats: Decodable, Sendable {
|
||||
public let domainCount: Int?
|
||||
public let statusCount: Int?
|
||||
public let userCount: Int?
|
||||
|
@ -107,7 +107,7 @@ extension Instance {
|
|||
}
|
||||
|
||||
extension Instance {
|
||||
public struct Configuration: Decodable {
|
||||
public struct Configuration: Decodable, Sendable {
|
||||
public let statuses: StatusesConfiguration
|
||||
public let mediaAttachments: MediaAttachmentsConfiguration
|
||||
/// Use Instance.pollsConfiguration to support older instance that don't have this nested
|
||||
|
@ -122,7 +122,7 @@ extension Instance {
|
|||
}
|
||||
|
||||
extension Instance {
|
||||
public struct StatusesConfiguration: Decodable {
|
||||
public struct StatusesConfiguration: Decodable, Sendable {
|
||||
public let maxCharacters: Int
|
||||
public let maxMediaAttachments: Int
|
||||
public let charactersReservedPerURL: Int
|
||||
|
@ -136,7 +136,7 @@ extension Instance {
|
|||
}
|
||||
|
||||
extension Instance {
|
||||
public struct MediaAttachmentsConfiguration: Decodable {
|
||||
public struct MediaAttachmentsConfiguration: Decodable, Sendable {
|
||||
public let supportedMIMETypes: [String]
|
||||
public let imageSizeLimit: Int
|
||||
public let imageMatrixLimit: Int
|
||||
|
@ -156,7 +156,7 @@ extension Instance {
|
|||
}
|
||||
|
||||
extension Instance {
|
||||
public struct PollsConfiguration: Decodable {
|
||||
public struct PollsConfiguration: Decodable, Sendable {
|
||||
public let maxOptions: Int
|
||||
public let maxCharactersPerOption: Int
|
||||
public let minExpiration: TimeInterval
|
||||
|
@ -172,7 +172,7 @@ extension Instance {
|
|||
}
|
||||
|
||||
extension Instance {
|
||||
public struct Rule: Decodable, Identifiable {
|
||||
public struct Rule: Decodable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let text: String
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class List: Decodable, Equatable, Hashable {
|
||||
public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
|
||||
|
@ -16,6 +16,11 @@ public class List: Decodable, Equatable, Hashable {
|
|||
return .list(id: id)
|
||||
}
|
||||
|
||||
public init(id: String, title: String) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
}
|
||||
|
||||
public static func ==(lhs: List, rhs: List) -> Bool {
|
||||
return lhs.id == rhs.id && lhs.title == rhs.title
|
||||
}
|
||||
|
@ -25,28 +30,28 @@ public class List: Decodable, Equatable, Hashable {
|
|||
hasher.combine(title)
|
||||
}
|
||||
|
||||
public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts")
|
||||
public static func getAccounts(_ listID: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(listID)/accounts")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
public static func update(_ list: List, title: String) -> Request<List> {
|
||||
return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: ParametersBody(["title" => title]))
|
||||
public static func update(_ listID: String, title: String) -> Request<List> {
|
||||
return Request<List>(method: .put, path: "/api/v1/lists/\(listID)", body: ParametersBody(["title" => title]))
|
||||
}
|
||||
|
||||
public static func delete(_ list: List) -> Request<Empty> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)")
|
||||
public static func delete(_ listID: String) -> Request<Empty> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(listID)")
|
||||
}
|
||||
|
||||
public static func add(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
|
||||
return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
|
||||
public static func add(_ listID: String, accounts accountIDs: [String]) -> Request<Empty> {
|
||||
return Request<Empty>(method: .post, path: "/api/v1/lists/\(listID)/accounts", body: ParametersBody(
|
||||
"account_ids" => accountIDs
|
||||
))
|
||||
}
|
||||
|
||||
public static func remove(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
|
||||
public static func remove(_ listID: String, accounts accountIDs: [String]) -> Request<Empty> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(listID)/accounts", body: ParametersBody(
|
||||
"account_ids" => accountIDs
|
||||
))
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class LoginSettings: Decodable {
|
||||
public struct LoginSettings: Decodable, Sendable {
|
||||
public let accessToken: String
|
||||
private let scope: String?
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public struct Mention: Codable {
|
||||
public struct Mention: Codable, Sendable {
|
||||
public let url: WebURL
|
||||
public let username: String
|
||||
public let acct: String
|
||||
|
|
|
@ -8,11 +8,11 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct NodeInfo: Decodable {
|
||||
public struct NodeInfo: Decodable, Sendable {
|
||||
public let version: String
|
||||
public let software: Software
|
||||
|
||||
public struct Software: Decodable {
|
||||
public struct Software: Decodable, Sendable {
|
||||
public let name: String
|
||||
public let version: String
|
||||
}
|
||||
|
|
|
@ -8,14 +8,14 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class Notification: Decodable {
|
||||
public struct Notification: Decodable, Sendable {
|
||||
public let id: String
|
||||
public let kind: Kind
|
||||
public let createdAt: Date
|
||||
public let account: Account
|
||||
public let status: Status?
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.id = try container.decode(String.self, forKey: .id)
|
||||
|
@ -45,7 +45,7 @@ public class Notification: Decodable {
|
|||
}
|
||||
|
||||
extension Notification {
|
||||
public enum Kind: String, Decodable, CaseIterable {
|
||||
public enum Kind: String, Decodable, CaseIterable, Sendable {
|
||||
case mention
|
||||
case reblog
|
||||
case favourite
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public final class Poll: Codable {
|
||||
public struct Poll: Codable, Sendable {
|
||||
public let id: String
|
||||
public let expiresAt: Date?
|
||||
public let expired: Bool
|
||||
|
@ -43,7 +43,7 @@ public final class Poll: Codable {
|
|||
}
|
||||
|
||||
extension Poll {
|
||||
public final class Option: Codable {
|
||||
public struct Option: Codable, Sendable {
|
||||
public let title: String
|
||||
public let votesCount: Int?
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
//
|
||||
// ListProtocol.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 2/25/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol ListProtocol {
|
||||
var id: String { get }
|
||||
var title: String { get }
|
||||
}
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class PushSubscription: Decodable {
|
||||
public struct PushSubscription: Decodable, Sendable {
|
||||
public let id: String
|
||||
public let endpoint: URL
|
||||
public let serverKey: String
|
||||
|
|
|
@ -8,12 +8,12 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class RegisteredApplication: Decodable {
|
||||
public struct RegisteredApplication: Decodable, Sendable {
|
||||
public let id: String
|
||||
public let clientID: String
|
||||
public let clientSecret: String
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
// Pixelfed API returns id/client_id as numbers instead of strings
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class Relationship: Decodable {
|
||||
public struct Relationship: Decodable, Sendable {
|
||||
public let id: String
|
||||
public let following: Bool
|
||||
public let followedBy: Bool
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class Report: Decodable {
|
||||
public struct Report: Decodable, Sendable {
|
||||
public let id: String
|
||||
public let actionTaken: Bool
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public enum Scope: String {
|
||||
public enum Scope: String, Sendable {
|
||||
case read
|
||||
case write
|
||||
case follow
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public enum SearchResultType: String {
|
||||
public enum SearchResultType: String, Sendable {
|
||||
case accounts
|
||||
case hashtags
|
||||
case statuses
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class SearchResults: Decodable {
|
||||
public struct SearchResults: Decodable, Sendable {
|
||||
public let accounts: [Account]
|
||||
public let statuses: [Status]
|
||||
public let hashtags: [Hashtag]
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public final class Status: StatusProtocol, Decodable {
|
||||
public final class Status: StatusProtocol, Decodable, Sendable {
|
||||
public let id: String
|
||||
public let uri: String
|
||||
public let url: WebURL?
|
||||
|
@ -44,6 +44,47 @@ public final class Status: StatusProtocol, Decodable {
|
|||
|
||||
public var applicationName: String? { application?.name }
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.id = try container.decode(String.self, forKey: .id)
|
||||
self.uri = try container.decode(String.self, forKey: .uri)
|
||||
self.url = try container.decodeIfPresent(WebURL.self, forKey: .url)
|
||||
self.account = try container.decode(Account.self, forKey: .account)
|
||||
self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID)
|
||||
self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID)
|
||||
self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog)
|
||||
self.content = try container.decode(String.self, forKey: .content)
|
||||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
self.emojis = try container.decode([Emoji].self, forKey: .emojis)
|
||||
self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount)
|
||||
self.favouritesCount = try container.decode(Int.self, forKey: .favouritesCount)
|
||||
self.reblogged = try container.decodeIfPresent(Bool.self, forKey: .reblogged)
|
||||
self.favourited = try container.decodeIfPresent(Bool.self, forKey: .favourited)
|
||||
self.muted = try container.decodeIfPresent(Bool.self, forKey: .muted)
|
||||
self.sensitive = try container.decode(Bool.self, forKey: .sensitive)
|
||||
self.spoilerText = try container.decode(String.self, forKey: .spoilerText)
|
||||
if let visibility = try? container.decode(Status.Visibility.self, forKey: .visibility) {
|
||||
self.visibility = visibility
|
||||
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly)
|
||||
} else if let s = try? container.decode(String.self, forKey: .visibility),
|
||||
s == "local" {
|
||||
// hacky workaround for #332, akkoma describes local posts with a separate visibility
|
||||
self.visibility = .public
|
||||
self.localOnly = true
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .visibility, in: container, debugDescription: "Could not decode visibility")
|
||||
}
|
||||
self.attachments = try container.decode([Attachment].self, forKey: .attachments)
|
||||
self.mentions = try container.decode([Mention].self, forKey: .mentions)
|
||||
self.hashtags = try container.decode([Hashtag].self, forKey: .hashtags)
|
||||
self.application = try container.decodeIfPresent(Application.self, forKey: .application)
|
||||
self.language = try container.decodeIfPresent(String.self, forKey: .language)
|
||||
self.pinned = try container.decodeIfPresent(Bool.self, forKey: .pinned)
|
||||
self.bookmarked = try container.decodeIfPresent(Bool.self, forKey: .bookmarked)
|
||||
self.card = try container.decodeIfPresent(Card.self, forKey: .card)
|
||||
self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll)
|
||||
}
|
||||
|
||||
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
|
||||
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(statusID)/context")
|
||||
}
|
||||
|
@ -147,7 +188,7 @@ public final class Status: StatusProtocol, Decodable {
|
|||
}
|
||||
|
||||
extension Status {
|
||||
public enum Visibility: String, Codable, CaseIterable {
|
||||
public enum Visibility: String, Codable, CaseIterable, Sendable {
|
||||
case `public`
|
||||
case unlisted
|
||||
case `private`
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public enum StatusContentType: String, Codable, CaseIterable {
|
||||
public enum StatusContentType: String, Codable, CaseIterable, Sendable {
|
||||
case plain, markdown, html
|
||||
|
||||
var mimeType: String {
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct Suggestion: Decodable {
|
||||
public struct Suggestion: Decodable, Sendable {
|
||||
public let source: Source
|
||||
public let account: Account
|
||||
|
||||
|
@ -17,7 +17,7 @@ public struct Suggestion: Decodable {
|
|||
}
|
||||
|
||||
extension Suggestion {
|
||||
public enum Source: String, Decodable {
|
||||
public enum Source: String, Decodable, Sendable {
|
||||
case staff
|
||||
case pastInteractions = "past_interactions"
|
||||
case global
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public enum Timeline: Equatable, Hashable {
|
||||
public enum Timeline: Equatable, Hashable, Sendable {
|
||||
case home
|
||||
case `public`(local: Bool)
|
||||
case tag(hashtag: String)
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
//
|
||||
// TimelineMarkers.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 2/14/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct TimelineMarkers: Decodable, Sendable {
|
||||
public let home: Marker?
|
||||
public let notifications: Marker?
|
||||
|
||||
public static func request(timelines: [Timeline]) -> Request<TimelineMarkers> {
|
||||
return Request(method: .get, path: "/api/v1/markers", queryParameters: "timeline[]" => timelines.map(\.rawValue))
|
||||
}
|
||||
|
||||
public static func update(timeline: Timeline, lastReadID: String) -> Request<Empty> {
|
||||
return Request(method: .post, path: "/api/v1/markers", body: ParametersBody([
|
||||
"\(timeline.rawValue)[last_read_id]" => lastReadID,
|
||||
]))
|
||||
}
|
||||
|
||||
public enum Timeline: String {
|
||||
case home
|
||||
case notifications
|
||||
}
|
||||
|
||||
public struct Marker: Decodable, Sendable {
|
||||
public let lastReadID: String
|
||||
public let version: Int
|
||||
public let updatedAt: Date
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case lastReadID = "last_read_id"
|
||||
case version
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
struct WellKnown: Decodable {
|
||||
struct WellKnown: Decodable, Sendable {
|
||||
let links: [Link]
|
||||
|
||||
struct Link: Decodable {
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
protocol Body {
|
||||
protocol Body: Sendable {
|
||||
var mimeType: String? { get }
|
||||
var data: Data? { get }
|
||||
}
|
||||
|
@ -76,7 +76,7 @@ struct FormDataBody: Body {
|
|||
}
|
||||
}
|
||||
|
||||
struct JsonBody<T: Encodable>: Body {
|
||||
struct JsonBody<T: Encodable & Sendable>: Body {
|
||||
let value: T
|
||||
|
||||
init(_ value: T) {
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct Endpoint: ExpressibleByStringInterpolation, CustomStringConvertible {
|
||||
public struct Endpoint: ExpressibleByStringInterpolation, CustomStringConvertible, Sendable {
|
||||
let components: [Component]
|
||||
|
||||
public init(stringLiteral value: StringLiteralType) {
|
||||
|
@ -54,7 +54,7 @@ public struct Endpoint: ExpressibleByStringInterpolation, CustomStringConvertibl
|
|||
}
|
||||
}
|
||||
|
||||
enum Component {
|
||||
enum Component: Sendable {
|
||||
case literal(String)
|
||||
case interpolated(String)
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct FormAttachment {
|
||||
public struct FormAttachment: Sendable {
|
||||
let mimeType: String
|
||||
let data: Data
|
||||
let fileName: String
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public enum Method {
|
||||
public enum Method: Sendable {
|
||||
case get, post, put, patch, delete
|
||||
}
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
struct Parameter {
|
||||
struct Parameter: Sendable {
|
||||
let name: String
|
||||
let value: String?
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct Request<ResultType: Decodable> {
|
||||
public struct Request<ResultType: Decodable>: Sendable {
|
||||
let method: Method
|
||||
let endpoint: Endpoint
|
||||
let body: Body
|
||||
|
|
|
@ -8,13 +8,24 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public enum RequestRange {
|
||||
public enum RequestRange: Sendable {
|
||||
case `default`
|
||||
case count(Int)
|
||||
/// Chronologically immediately before the given ID
|
||||
case before(id: String, count: Int?)
|
||||
/// Chronologically immediately after the given ID
|
||||
case after(id: String, count: Int?)
|
||||
|
||||
public func withCount(_ count: Int) -> Self {
|
||||
switch self {
|
||||
case .default, .count(_):
|
||||
return .count(count)
|
||||
case .before(id: let id, count: _):
|
||||
return .before(id: id, count: count)
|
||||
case .after(id: let id, count: _):
|
||||
return .after(id: id, count: count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RequestRange {
|
||||
|
|
|
@ -8,6 +8,6 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct Empty: Decodable {
|
||||
public struct Empty: Decodable, Sendable {
|
||||
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct Pagination {
|
||||
public struct Pagination: Sendable {
|
||||
public let older: RequestRange?
|
||||
public let newer: RequestRange?
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public enum Response<Result: Decodable> {
|
||||
public enum Response<Result: Decodable & Sendable>: Sendable {
|
||||
case success(Result, Pagination?)
|
||||
case failure(Client.Error)
|
||||
}
|
||||
|
|
|
@ -8,7 +8,8 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class CollapseState: Equatable {
|
||||
@MainActor
|
||||
public final class CollapseState: Sendable {
|
||||
public var collapsible: Bool?
|
||||
public var collapsed: Bool?
|
||||
|
||||
|
@ -33,8 +34,4 @@ public class CollapseState: Equatable {
|
|||
public static var unknown: CollapseState {
|
||||
CollapseState(collapsible: nil, collapsed: nil)
|
||||
}
|
||||
|
||||
public static func == (lhs: CollapseState, rhs: CollapseState) -> Bool {
|
||||
lhs.collapsible == rhs.collapsible && lhs.collapsed == rhs.collapsed
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ public struct NotificationGroup: Identifiable, Hashable {
|
|||
public let kind: Notification.Kind
|
||||
public let statusState: CollapseState?
|
||||
|
||||
@MainActor
|
||||
init?(notifications: [Notification]) {
|
||||
guard !notifications.isEmpty else { return nil }
|
||||
self.notifications = notifications
|
||||
|
@ -51,6 +52,7 @@ public struct NotificationGroup: Identifiable, Hashable {
|
|||
notifications.append(contentsOf: group.notifications)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
||||
var groups = [NotificationGroup]()
|
||||
for notification in notifications {
|
||||
|
|
|
@ -62,7 +62,7 @@ public class GameModel: NSObject, NSCopying, GKGameModel {
|
|||
case .playAnywhere(update.mark), .playSpecific(update.mark, column: update.subBoard.column, row: update.subBoard.row):
|
||||
break
|
||||
default:
|
||||
fatalError()
|
||||
return
|
||||
}
|
||||
controller.play(on: update.subBoard, column: update.column, row: update.row)
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
public class GameController: ObservableObject {
|
||||
|
||||
|
|
|
@ -20,6 +20,9 @@
|
|||
D60088EF2980D8B5005B4D00 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D60088EE2980D8B5005B4D00 /* StoreKit.framework */; };
|
||||
D60088F22980DAA0005B4D00 /* TipJarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60088F12980DAA0005B4D00 /* TipJarView.swift */; };
|
||||
D60089192981FEBA005B4D00 /* ConfettiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60089182981FEBA005B4D00 /* ConfettiView.swift */; };
|
||||
D600891B29848289005B4D00 /* PinnedTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = D600891A29848289005B4D00 /* PinnedTimeline.swift */; };
|
||||
D600891D298482F0005B4D00 /* PinnedTimelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D600891C298482F0005B4D00 /* PinnedTimelineTests.swift */; };
|
||||
D600891F29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D600891E29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift */; };
|
||||
D601FA5B29787AB100A8E8B5 /* AccountFollowsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */; };
|
||||
D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */; };
|
||||
D601FA5F297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */; };
|
||||
|
@ -38,8 +41,6 @@
|
|||
D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */; };
|
||||
D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */; };
|
||||
D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1627F8BB210080E273 /* VersionTests.swift */; };
|
||||
D611C2CF232DC61100C86A49 /* HashtagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */; };
|
||||
D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */; };
|
||||
D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */; };
|
||||
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; };
|
||||
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; };
|
||||
|
@ -102,7 +103,6 @@
|
|||
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627943123A5466600D38C68 /* SelectableTableViewCell.swift */; };
|
||||
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */; };
|
||||
D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */; };
|
||||
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944923A6AD6100D38C68 /* BookmarksTableViewController.swift */; };
|
||||
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */; };
|
||||
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */; };
|
||||
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627FF75217E923E00CC0648 /* DraftsManager.swift */; };
|
||||
|
@ -200,6 +200,7 @@
|
|||
D681E4D7246E32290053414F /* StatusActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D6246E32290053414F /* StatusActivityItemSource.swift */; };
|
||||
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */; };
|
||||
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */; };
|
||||
D68329EF299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68329EE299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift */; };
|
||||
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */; };
|
||||
D6895DC228D65274006341DA /* CustomAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC128D65274006341DA /* CustomAlertController.swift */; };
|
||||
D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */; };
|
||||
|
@ -214,11 +215,16 @@
|
|||
D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */; };
|
||||
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */; };
|
||||
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; };
|
||||
D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525C24A3E8F00054355A /* SearchViewController.swift */; };
|
||||
D68E525D24A3E8F00054355A /* InlineTrendsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525C24A3E8F00054355A /* InlineTrendsViewController.swift */; };
|
||||
D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */; };
|
||||
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */; };
|
||||
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */; };
|
||||
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D690797224A4EF9700023A34 /* UIBezierPath+Helpers.swift */; };
|
||||
D691771129A2B76A0054D7EF /* MainActor+Unsafe.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */; };
|
||||
D691771529A6FCAB0054D7EF /* StateRestorableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771429A6FCAB0054D7EF /* StateRestorableViewController.swift */; };
|
||||
D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */; };
|
||||
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */; };
|
||||
D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */; };
|
||||
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */; };
|
||||
D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */; };
|
||||
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */; };
|
||||
|
@ -251,7 +257,6 @@
|
|||
D6A5BB2D23BBA9C4003BF21D /* MyProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */; };
|
||||
D6A5BB2F23BBAC97003BF21D /* DelegatingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */; };
|
||||
D6A5BB3123BBAD87003BF21D /* JSONResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB3023BBAD87003BF21D /* JSONResponse.swift */; };
|
||||
D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C10425B6138A00298D0F /* StatusTablePrefetching.swift */; };
|
||||
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C10E25B62D2400298D0F /* DiskCache.swift */; };
|
||||
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11425B62E9700298D0F /* CacheExpiry.swift */; };
|
||||
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */; };
|
||||
|
@ -293,6 +298,12 @@
|
|||
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; };
|
||||
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; };
|
||||
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; };
|
||||
D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */; };
|
||||
D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */; };
|
||||
D6C3F4F9298EDBF20009FCFF /* ConversationTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */; };
|
||||
D6C3F4FB299035650009FCFF /* TrendsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4FA299035650009FCFF /* TrendsViewController.swift */; };
|
||||
D6C3F5172991C1A00009FCFF /* View+AppListStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F5162991C1A00009FCFF /* View+AppListStyle.swift */; };
|
||||
D6C3F5192991F5D60009FCFF /* TimelineJumpButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F5182991F5D60009FCFF /* TimelineJumpButton.swift */; };
|
||||
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; };
|
||||
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; };
|
||||
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; };
|
||||
|
@ -319,10 +330,16 @@
|
|||
D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; };
|
||||
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */; };
|
||||
D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D706A62948D4D0000827ED /* TimlineState.swift */; };
|
||||
D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; };
|
||||
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; };
|
||||
D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; };
|
||||
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */; };
|
||||
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A44273D6C5700386A6C /* GIFImageView.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 */; };
|
||||
D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; };
|
||||
D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */; };
|
||||
D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */; };
|
||||
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */; };
|
||||
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */; };
|
||||
D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* Weak.swift */; };
|
||||
|
@ -422,6 +439,9 @@
|
|||
D60088F02980D938005B4D00 /* Tusker.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Tusker.storekit; sourceTree = "<group>"; };
|
||||
D60088F12980DAA0005B4D00 /* TipJarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipJarView.swift; sourceTree = "<group>"; };
|
||||
D60089182981FEBA005B4D00 /* ConfettiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfettiView.swift; sourceTree = "<group>"; };
|
||||
D600891A29848289005B4D00 /* PinnedTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedTimeline.swift; sourceTree = "<group>"; };
|
||||
D600891C298482F0005B4D00 /* PinnedTimelineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedTimelineTests.swift; sourceTree = "<group>"; };
|
||||
D600891E29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddInstancePinnedTimelineView.swift; sourceTree = "<group>"; };
|
||||
D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFollowsListViewController.swift; sourceTree = "<group>"; };
|
||||
D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationCollectionViewController.swift; sourceTree = "<group>"; };
|
||||
D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandThreadCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
|
@ -439,8 +459,6 @@
|
|||
D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinksViewController.swift; sourceTree = "<group>"; };
|
||||
D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinkTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D6114E1627F8BB210080E273 /* VersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionTests.swift; sourceTree = "<group>"; };
|
||||
D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = HashtagTableViewCell.xib; sourceTree = "<group>"; };
|
||||
D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLoadMoreCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = "<group>"; };
|
||||
|
@ -502,7 +520,6 @@
|
|||
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigableTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BasicTableViewCell.xib; sourceTree = "<group>"; };
|
||||
D627944923A6AD6100D38C68 /* BookmarksTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksTableViewController.swift; sourceTree = "<group>"; };
|
||||
D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTimelineViewController.swift; sourceTree = "<group>"; };
|
||||
D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListAccountsViewController.swift; sourceTree = "<group>"; };
|
||||
D627FF75217E923E00CC0648 /* DraftsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsManager.swift; sourceTree = "<group>"; };
|
||||
|
@ -583,6 +600,7 @@
|
|||
D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Equatable.swift"; sourceTree = "<group>"; };
|
||||
D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; };
|
||||
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = "<group>"; };
|
||||
D671A6BE299DA96100A81FEA /* Tusker-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Tusker-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuPicker.swift; sourceTree = "<group>"; };
|
||||
D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Pachyderm; path = Packages/Pachyderm; sourceTree = "<group>"; };
|
||||
D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
|
||||
|
@ -601,6 +619,7 @@
|
|||
D681E4D6246E32290053414F /* StatusActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActivityItemSource.swift; sourceTree = "<group>"; };
|
||||
D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountActivityItemSource.swift; sourceTree = "<group>"; };
|
||||
D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeDrawingViewController.swift; sourceTree = "<group>"; };
|
||||
D68329EE299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreTrendsFooterCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedProgressView.swift; sourceTree = "<group>"; };
|
||||
D6895DC128D65274006341DA /* CustomAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAlertController.swift; sourceTree = "<group>"; };
|
||||
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmReblogStatusPreviewView.swift; sourceTree = "<group>"; };
|
||||
|
@ -616,11 +635,16 @@
|
|||
D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerControlCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuxiliarySceneDelegate.swift; sourceTree = "<group>"; };
|
||||
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = "<group>"; };
|
||||
D68E525C24A3E8F00054355A /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = "<group>"; };
|
||||
D68E525C24A3E8F00054355A /* InlineTrendsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTrendsViewController.swift; sourceTree = "<group>"; };
|
||||
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiSourceEmojiLabel.swift; sourceTree = "<group>"; };
|
||||
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseEmojiLabel.swift; sourceTree = "<group>"; };
|
||||
D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.swift; sourceTree = "<group>"; };
|
||||
D690797224A4EF9700023A34 /* UIBezierPath+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBezierPath+Helpers.swift"; sourceTree = "<group>"; };
|
||||
D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainActor+Unsafe.swift"; sourceTree = "<group>"; };
|
||||
D691771429A6FCAB0054D7EF /* StateRestorableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateRestorableViewController.swift; sourceTree = "<group>"; };
|
||||
D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileNoContentCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderMovedOverlayView.swift; sourceTree = "<group>"; };
|
||||
D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityHandlingContext.swift; sourceTree = "<group>"; };
|
||||
D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagsViewController.swift; sourceTree = "<group>"; };
|
||||
D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDirectoryViewController.swift; sourceTree = "<group>"; };
|
||||
D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedProfileCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
|
@ -653,7 +677,6 @@
|
|||
D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileTests.swift; sourceTree = "<group>"; };
|
||||
D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelegatingResponse.swift; sourceTree = "<group>"; };
|
||||
D6A5BB3023BBAD87003BF21D /* JSONResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONResponse.swift; sourceTree = "<group>"; };
|
||||
D6A6C10425B6138A00298D0F /* StatusTablePrefetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTablePrefetching.swift; sourceTree = "<group>"; };
|
||||
D6A6C10E25B62D2400298D0F /* DiskCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskCache.swift; sourceTree = "<group>"; };
|
||||
D6A6C11425B62E9700298D0F /* CacheExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheExpiry.swift; sourceTree = "<group>"; };
|
||||
D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = "<group>"; };
|
||||
|
@ -695,6 +718,12 @@
|
|||
D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = "<group>"; };
|
||||
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; };
|
||||
D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPredicateStatusesViewController.swift; sourceTree = "<group>"; };
|
||||
D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = "<group>"; };
|
||||
D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTree.swift; sourceTree = "<group>"; };
|
||||
D6C3F4FA299035650009FCFF /* TrendsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendsViewController.swift; sourceTree = "<group>"; };
|
||||
D6C3F5162991C1A00009FCFF /* View+AppListStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+AppListStyle.swift"; sourceTree = "<group>"; };
|
||||
D6C3F5182991F5D60009FCFF /* TimelineJumpButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineJumpButton.swift; sourceTree = "<group>"; };
|
||||
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = "<group>"; };
|
||||
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; };
|
||||
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; };
|
||||
|
@ -728,10 +757,16 @@
|
|||
D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollingSegmentedControl.swift; sourceTree = "<group>"; };
|
||||
D6D706A62948D4D0000827ED /* TimlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimlineState.swift; sourceTree = "<group>"; };
|
||||
D6D706A829498C82000827ED /* Tusker.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Tusker.xcconfig; sourceTree = "<group>"; };
|
||||
D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; };
|
||||
D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = "<group>"; };
|
||||
D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = "<group>"; };
|
||||
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.swift; sourceTree = "<group>"; };
|
||||
D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewController.swift; sourceTree = "<group>"; };
|
||||
D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfilesViewController.swift; 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 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = "<group>"; };
|
||||
|
@ -835,8 +870,6 @@
|
|||
D611C2CC232DC5FC00C86A49 /* Hashtag Cell */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */,
|
||||
D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */,
|
||||
D6E77D08286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift */,
|
||||
);
|
||||
path = "Hashtag Cell";
|
||||
|
@ -852,6 +885,7 @@
|
|||
D627FF75217E923E00CC0648 /* DraftsManager.swift */,
|
||||
D61F75AE293AF50C00C0B37F /* EditedFilter.swift */,
|
||||
D65B4B532971F71D00DABDFB /* EditedReport.swift */,
|
||||
D600891A29848289005B4D00 /* PinnedTimeline.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
|
@ -873,6 +907,7 @@
|
|||
D61F75A4293ABD6F00C0B37F /* EditFilterView.swift */,
|
||||
D68A76E729527884001DA1B3 /* PinnedTimelinesView.swift */,
|
||||
D68A76E9295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift */,
|
||||
D600891E29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift */,
|
||||
);
|
||||
path = "Customize Timelines";
|
||||
sourceTree = "<group>";
|
||||
|
@ -907,6 +942,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
D6C82B4025C5BB7E0017F1E6 /* ExploreViewController.swift */,
|
||||
D68E525C24A3E8F00054355A /* InlineTrendsViewController.swift */,
|
||||
D6945C3323AC6431005C403C /* AddSavedHashtagViewController.swift */,
|
||||
D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */,
|
||||
D6945C3923AC75E2005C403C /* FindInstanceViewController.swift */,
|
||||
|
@ -921,16 +957,21 @@
|
|||
D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */,
|
||||
D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */,
|
||||
D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */,
|
||||
D6C3F4FA299035650009FCFF /* TrendsViewController.swift */,
|
||||
D68329EE299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift */,
|
||||
D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */,
|
||||
);
|
||||
path = Explore;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D627944823A6AD5100D38C68 /* Bookmarks */ = {
|
||||
D627944823A6AD5100D38C68 /* Local Predicate Statuses List */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D627944923A6AD6100D38C68 /* BookmarksTableViewController.swift */,
|
||||
D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */,
|
||||
D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */,
|
||||
D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */,
|
||||
);
|
||||
path = Bookmarks;
|
||||
path = "Local Predicate Statuses List";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D627944B23A9A02400D38C68 /* Lists */ = {
|
||||
|
@ -947,6 +988,7 @@
|
|||
children = (
|
||||
D62D2425217ABF63005076CC /* UserActivityType.swift */,
|
||||
D62D2421217AA7E1005076CC /* UserActivityManager.swift */,
|
||||
D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */,
|
||||
D62D2423217ABF3F005076CC /* NSUserActivity+Extensions.swift */,
|
||||
D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */,
|
||||
);
|
||||
|
@ -993,7 +1035,6 @@
|
|||
D6A3BC822321F69400FD64D5 /* Account List */,
|
||||
D6B053A023BD2BED00A066FA /* Asset Picker */,
|
||||
0411610522B457290030A9B7 /* Attachment Gallery */,
|
||||
D627944823A6AD5100D38C68 /* Bookmarks */,
|
||||
D641C787213DD862004B4513 /* Compose */,
|
||||
D641C785213DD83B004B4513 /* Conversation */,
|
||||
D6F2E960249E772F005846BB /* Crash Reporter */,
|
||||
|
@ -1002,6 +1043,7 @@
|
|||
D61F759729384D4200C0B37F /* Customize Timelines */,
|
||||
D641C788213DD86D004B4513 /* Large Image */,
|
||||
D627944B23A9A02400D38C68 /* Lists */,
|
||||
D627944823A6AD5100D38C68 /* Local Predicate Statuses List */,
|
||||
D641C782213DD7F0004B4513 /* Main */,
|
||||
D6F6A555291F4F0C00F496A8 /* Mute */,
|
||||
D641C786213DD852004B4513 /* Notifications */,
|
||||
|
@ -1026,6 +1068,7 @@
|
|||
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */,
|
||||
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */,
|
||||
D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */,
|
||||
D6C3F5182991F5D60009FCFF /* TimelineJumpButton.swift */,
|
||||
);
|
||||
path = Timeline;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1060,6 +1103,7 @@
|
|||
D61DC84C28F500D200B82C6E /* ProfileViewController.swift */,
|
||||
D61ABEF728EFC3F900B29151 /* ProfileStatusesViewController.swift */,
|
||||
D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */,
|
||||
D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */,
|
||||
);
|
||||
path = Profile;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1070,6 +1114,7 @@
|
|||
D65B4B6329771EFF00DABDFB /* ConversationViewController.swift */,
|
||||
D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */,
|
||||
D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */,
|
||||
D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */,
|
||||
);
|
||||
path = Conversation;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1156,6 +1201,7 @@
|
|||
D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */,
|
||||
D601FA60297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift */,
|
||||
D6ADB6EB28EA73CB009924AB /* StatusContentContainer.swift */,
|
||||
D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */,
|
||||
);
|
||||
path = Status;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1167,6 +1213,7 @@
|
|||
D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */,
|
||||
D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */,
|
||||
D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */,
|
||||
D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */,
|
||||
);
|
||||
path = "Profile Header";
|
||||
sourceTree = "<group>";
|
||||
|
@ -1249,6 +1296,7 @@
|
|||
D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */,
|
||||
D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */,
|
||||
D61F75872932DB6000C0B37F /* StatusSwipeAction.swift */,
|
||||
D6D94954298963A900C59229 /* Colors.swift */,
|
||||
);
|
||||
path = Preferences;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1278,6 +1326,8 @@
|
|||
D63CC70F2911F1E4000E19DE /* UIScrollView+Top.swift */,
|
||||
D61F758F29353B4300C0B37F /* FileManager+Size.swift */,
|
||||
D61F75AC293AF39000C0B37F /* Filter+Helpers.swift */,
|
||||
D6C3F5162991C1A00009FCFF /* View+AppListStyle.swift */,
|
||||
D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1389,7 +1439,6 @@
|
|||
D6BC9DD8232D8BCA002CA326 /* Search */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D68E525C24A3E8F00054355A /* SearchViewController.swift */,
|
||||
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */,
|
||||
);
|
||||
path = Search;
|
||||
|
@ -1404,6 +1453,7 @@
|
|||
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
|
||||
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
|
||||
D620483523D38075008A63EF /* ContentTextView.swift */,
|
||||
D6D9498E298EB79400C59229 /* CopyableLable.swift */,
|
||||
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
|
||||
D6969E9F240C8384002843CE /* EmojiLabel.swift */,
|
||||
D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */,
|
||||
|
@ -1456,8 +1506,8 @@
|
|||
D6E0DC8D216EDF1E00369478 /* Previewing.swift */,
|
||||
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */,
|
||||
D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */,
|
||||
D691771429A6FCAB0054D7EF /* StateRestorableViewController.swift */,
|
||||
D63CC7112911F57C000E19DE /* StatusBarTappableViewController.swift */,
|
||||
D6A6C10425B6138A00298D0F /* StatusTablePrefetching.swift */,
|
||||
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */,
|
||||
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
|
||||
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */,
|
||||
|
@ -1514,6 +1564,7 @@
|
|||
D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */,
|
||||
D6D4DDDB212518A200E1C4BB /* Info.plist */,
|
||||
D60088F02980D938005B4D00 /* Tusker.storekit */,
|
||||
D671A6BE299DA96100A81FEA /* Tusker-Bridging-Header.h */,
|
||||
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
|
||||
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
|
||||
D61F75B6293C119700C0B37F /* Filterer.swift */,
|
||||
|
@ -1561,6 +1612,7 @@
|
|||
D6114E1627F8BB210080E273 /* VersionTests.swift */,
|
||||
D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */,
|
||||
D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */,
|
||||
D600891C298482F0005B4D00 /* PinnedTimelineTests.swift */,
|
||||
D6D4DDE6212518A200E1C4BB /* Info.plist */,
|
||||
);
|
||||
path = TuskerTests;
|
||||
|
@ -1655,6 +1707,7 @@
|
|||
D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */,
|
||||
D65B4B6129771A3F00DABDFB /* FetchStatusService.swift */,
|
||||
D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */,
|
||||
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */,
|
||||
);
|
||||
path = API;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1761,7 +1814,7 @@
|
|||
TargetAttributes = {
|
||||
D6D4DDCB212518A000E1C4BB = {
|
||||
CreatedOnToolsVersion = 10.0;
|
||||
LastSwiftMigration = 1410;
|
||||
LastSwiftMigration = 1420;
|
||||
};
|
||||
D6D4DDDF212518A200E1C4BB = {
|
||||
CreatedOnToolsVersion = 10.0;
|
||||
|
@ -1816,7 +1869,6 @@
|
|||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */,
|
||||
D6E77D0D286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib in Resources */,
|
||||
D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */,
|
||||
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */,
|
||||
|
@ -1924,7 +1976,9 @@
|
|||
D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */,
|
||||
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */,
|
||||
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
|
||||
D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */,
|
||||
D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */,
|
||||
D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */,
|
||||
D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */,
|
||||
D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */,
|
||||
D60E2F292442372B005F8713 /* AccountMO.swift in Sources */,
|
||||
|
@ -1932,8 +1986,10 @@
|
|||
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */,
|
||||
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */,
|
||||
D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */,
|
||||
D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */,
|
||||
D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */,
|
||||
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */,
|
||||
D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */,
|
||||
D68E525D24A3E8F00054355A /* InlineTrendsViewController.swift in Sources */,
|
||||
D61F75BB293C183100C0B37F /* HTMLConverter.swift in Sources */,
|
||||
D61F75A5293ABD6F00C0B37F /* EditFilterView.swift in Sources */,
|
||||
D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */,
|
||||
|
@ -1964,7 +2020,6 @@
|
|||
0411610022B442870030A9B7 /* LoadingLargeImageViewController.swift in Sources */,
|
||||
D6A4DCE525537C7A00D9DE31 /* FastSwitchingAccountView.swift in Sources */,
|
||||
D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */,
|
||||
D611C2CF232DC61100C86A49 /* HashtagTableViewCell.swift in Sources */,
|
||||
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */,
|
||||
D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */,
|
||||
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */,
|
||||
|
@ -1973,6 +2028,7 @@
|
|||
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */,
|
||||
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */,
|
||||
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */,
|
||||
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */,
|
||||
D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */,
|
||||
D620483623D38075008A63EF /* ContentTextView.swift in Sources */,
|
||||
D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */,
|
||||
|
@ -1982,6 +2038,7 @@
|
|||
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */,
|
||||
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */,
|
||||
D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */,
|
||||
D6D94955298963A900C59229 /* Colors.swift in Sources */,
|
||||
D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */,
|
||||
D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */,
|
||||
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
|
||||
|
@ -2002,9 +2059,11 @@
|
|||
D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */,
|
||||
D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */,
|
||||
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */,
|
||||
D6C3F4F9298EDBF20009FCFF /* ConversationTree.swift in Sources */,
|
||||
D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */,
|
||||
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */,
|
||||
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */,
|
||||
D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */,
|
||||
D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */,
|
||||
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */,
|
||||
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
|
||||
|
@ -2022,6 +2081,7 @@
|
|||
D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */,
|
||||
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
|
||||
D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */,
|
||||
D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */,
|
||||
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */,
|
||||
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */,
|
||||
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */,
|
||||
|
@ -2031,13 +2091,16 @@
|
|||
D65B4B6629773AE600DABDFB /* DeleteStatusService.swift in Sources */,
|
||||
D61DC84628F498F200B82C6E /* Logging.swift in Sources */,
|
||||
D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */,
|
||||
D600891B29848289005B4D00 /* PinnedTimeline.swift in Sources */,
|
||||
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */,
|
||||
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */,
|
||||
D6ADB6EE28EA74E8009924AB /* UIView+Configure.swift in Sources */,
|
||||
D68329EF299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift in Sources */,
|
||||
D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */,
|
||||
D61F75B1293BD85300C0B37F /* CreateFilterService.swift in Sources */,
|
||||
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */,
|
||||
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */,
|
||||
D600891F29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift in Sources */,
|
||||
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */,
|
||||
D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */,
|
||||
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */,
|
||||
|
@ -2054,7 +2117,7 @@
|
|||
D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */,
|
||||
D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */,
|
||||
D68A76EE295369C7001DA1B3 /* AcknowledgementsView.swift in Sources */,
|
||||
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */,
|
||||
D6C3F5192991F5D60009FCFF /* TimelineJumpButton.swift in Sources */,
|
||||
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
|
||||
D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */,
|
||||
D65B4B8B297879E900DABDFB /* AccountFollowsViewController.swift in Sources */,
|
||||
|
@ -2071,6 +2134,7 @@
|
|||
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */,
|
||||
D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */,
|
||||
D6A3A3822956123A0036B6EF /* TimelinePosition.swift in Sources */,
|
||||
D691771529A6FCAB0054D7EF /* StateRestorableViewController.swift in Sources */,
|
||||
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
|
||||
D6B93667281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift in Sources */,
|
||||
D61F75BD293D099600C0B37F /* Lazy.swift in Sources */,
|
||||
|
@ -2102,6 +2166,7 @@
|
|||
D663626221360B1900C9CBA2 /* Preferences.swift in Sources */,
|
||||
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
|
||||
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
|
||||
D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */,
|
||||
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
|
||||
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */,
|
||||
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */,
|
||||
|
@ -2132,6 +2197,7 @@
|
|||
D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */,
|
||||
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */,
|
||||
D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */,
|
||||
D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */,
|
||||
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
|
||||
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,
|
||||
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */,
|
||||
|
@ -2147,7 +2213,9 @@
|
|||
D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */,
|
||||
D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */,
|
||||
D65B4B58297203A700DABDFB /* ReportSelectRulesView.swift in Sources */,
|
||||
D6C3F4FB299035650009FCFF /* TrendsViewController.swift in Sources */,
|
||||
D6B81F442560390300F6E31D /* MenuController.swift in Sources */,
|
||||
D691771129A2B76A0054D7EF /* MainActor+Unsafe.swift in Sources */,
|
||||
D65B4B542971F71D00DABDFB /* EditedReport.swift in Sources */,
|
||||
D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */,
|
||||
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */,
|
||||
|
@ -2168,6 +2236,7 @@
|
|||
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */,
|
||||
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
|
||||
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */,
|
||||
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */,
|
||||
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */,
|
||||
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
|
||||
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
|
||||
|
@ -2203,11 +2272,11 @@
|
|||
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
|
||||
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
|
||||
D61F75B3293BD89C00C0B37F /* UpdateFilterService.swift in Sources */,
|
||||
D6C3F5172991C1A00009FCFF /* View+AppListStyle.swift in Sources */,
|
||||
D65B4B6A297777D900DABDFB /* StatusNotFoundView.swift in Sources */,
|
||||
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
|
||||
D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */,
|
||||
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */,
|
||||
D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */,
|
||||
D60088F22980DAA0005B4D00 /* TipJarView.swift in Sources */,
|
||||
D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */,
|
||||
D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */,
|
||||
|
@ -2228,6 +2297,7 @@
|
|||
D61F75A129396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift in Sources */,
|
||||
D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */,
|
||||
D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */,
|
||||
D600891D298482F0005B4D00 /* PinnedTimelineTests.swift in Sources */,
|
||||
D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */,
|
||||
D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */,
|
||||
D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */,
|
||||
|
@ -2369,10 +2439,11 @@
|
|||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
CURRENT_PROJECT_VERSION = 76;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
|
@ -2380,11 +2451,12 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2023.3;
|
||||
MARKETING_VERSION = 2023.4;
|
||||
OTHER_CODE_SIGN_FLAGS = "";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Tusker/Tusker-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,6";
|
||||
};
|
||||
|
@ -2437,7 +2509,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
CURRENT_PROJECT_VERSION = 76;
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
||||
|
@ -2446,7 +2518,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2023.3;
|
||||
MARKETING_VERSION = 2023.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
@ -2585,10 +2657,11 @@
|
|||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
CURRENT_PROJECT_VERSION = 76;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
|
@ -2596,13 +2669,15 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2023.3;
|
||||
MARKETING_VERSION = 2023.4;
|
||||
OTHER_CODE_SIGN_FLAGS = "";
|
||||
OTHER_LDFLAGS = "";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Tusker/Tusker-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,6";
|
||||
};
|
||||
|
@ -2613,10 +2688,11 @@
|
|||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
CURRENT_PROJECT_VERSION = 76;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
|
@ -2624,11 +2700,12 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2023.3;
|
||||
MARKETING_VERSION = 2023.4;
|
||||
OTHER_CODE_SIGN_FLAGS = "";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Tusker/Tusker-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,6";
|
||||
};
|
||||
|
@ -2721,7 +2798,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
CURRENT_PROJECT_VERSION = 76;
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
||||
|
@ -2730,7 +2807,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2023.3;
|
||||
MARKETING_VERSION = 2023.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
@ -2747,7 +2824,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
CURRENT_PROJECT_VERSION = 76;
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
||||
|
@ -2756,7 +2833,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2023.3;
|
||||
MARKETING_VERSION = 2023.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
@ -2835,7 +2912,7 @@
|
|||
repositoryURL = "https://github.com/getsentry/sentry-cocoa.git";
|
||||
requirement = {
|
||||
kind = upToNextMinorVersion;
|
||||
minimumVersion = 7.29.0;
|
||||
minimumVersion = 8.0.0;
|
||||
};
|
||||
};
|
||||
D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */ = {
|
||||
|
|
|
@ -13,11 +13,11 @@ import Pachyderm
|
|||
class CreateListService {
|
||||
private let mastodonController: MastodonController
|
||||
private let present: (UIViewController) -> Void
|
||||
private let didCreateList: (@MainActor (List) -> Void)?
|
||||
private let didCreateList: (@MainActor (List) async -> Void)?
|
||||
|
||||
private var createAction: UIAlertAction?
|
||||
|
||||
init(mastodonController: MastodonController, present: @escaping (UIViewController) -> Void, didCreateList: (@MainActor (List) -> Void)?) {
|
||||
init(mastodonController: MastodonController, present: @escaping (UIViewController) -> Void, didCreateList: (@MainActor (List) async -> Void)?) {
|
||||
self.mastodonController = mastodonController
|
||||
self.present = present
|
||||
self.didCreateList = didCreateList
|
||||
|
@ -50,7 +50,7 @@ class CreateListService {
|
|||
let request = Client.createList(title: title)
|
||||
let (list, _) = try await mastodonController.run(request)
|
||||
mastodonController.addedList(list)
|
||||
self.didCreateList?(list)
|
||||
await self.didCreateList?(list)
|
||||
} catch {
|
||||
let alert = UIAlertController(title: "Error Creating List", message: error.localizedDescription, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
|
|
|
@ -48,7 +48,7 @@ class DeleteListService {
|
|||
|
||||
private func deleteList() async {
|
||||
do {
|
||||
let request = List.delete(list)
|
||||
let request = List.delete(list.id)
|
||||
_ = try await mastodonController.run(request)
|
||||
mastodonController.deletedList(list)
|
||||
} catch {
|
||||
|
|
|
@ -101,6 +101,10 @@ struct InstanceFeatures {
|
|||
hasMastodonVersion(3, 5, 0)
|
||||
}
|
||||
|
||||
var pollVotersCount: Bool {
|
||||
instanceType.isMastodon
|
||||
}
|
||||
|
||||
mutating func update(instance: Instance, nodeInfo: NodeInfo?) {
|
||||
let ver = instance.version.lowercased()
|
||||
if ver.contains("glitch") {
|
||||
|
@ -269,5 +273,5 @@ private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) {
|
|||
"version": nodeInfo.software.version,
|
||||
]
|
||||
}
|
||||
SentrySDK.addBreadcrumb(crumb: crumb)
|
||||
SentrySDK.addBreadcrumb(crumb)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// LogoutService.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 1/27/23.
|
||||
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
class LogoutService {
|
||||
let accountInfo: LocalData.UserAccountInfo
|
||||
private let mastodonController: MastodonController
|
||||
|
||||
init(accountInfo: LocalData.UserAccountInfo) {
|
||||
self.accountInfo = accountInfo
|
||||
self.mastodonController = MastodonController.getForAccount(accountInfo)
|
||||
}
|
||||
|
||||
func run() {
|
||||
Task.detached {
|
||||
try? await self.mastodonController.client.revokeAccessToken()
|
||||
}
|
||||
MastodonController.removeForAccount(accountInfo)
|
||||
LocalData.shared.removeAccount(accountInfo)
|
||||
let psc = mastodonController.persistentContainer.persistentStoreCoordinator
|
||||
for store in psc.persistentStores {
|
||||
guard let url = store.url else {
|
||||
continue
|
||||
}
|
||||
try? psc.destroyPersistentStore(at: url, type: .sqlite)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -31,12 +31,16 @@ class MastodonController: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
static func removeForAccount(_ account: LocalData.UserAccountInfo) {
|
||||
all.removeValue(forKey: account)
|
||||
}
|
||||
|
||||
static func resetAll() {
|
||||
all = [:]
|
||||
}
|
||||
|
||||
private let transient: Bool
|
||||
private(set) lazy var persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient)
|
||||
private(set) nonisolated lazy var persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient)
|
||||
|
||||
let instanceURL: URL
|
||||
var accountInfo: LocalData.UserAccountInfo?
|
||||
|
@ -106,7 +110,7 @@ class MastodonController: ObservableObject {
|
|||
return response
|
||||
}
|
||||
|
||||
func run<Result>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
|
||||
func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
|
||||
let response = await runResponse(request)
|
||||
try Task.checkCancellation()
|
||||
switch response {
|
||||
|
@ -162,6 +166,8 @@ class MastodonController: ObservableObject {
|
|||
|
||||
loadAccountPreferences()
|
||||
|
||||
lists = loadCachedLists()
|
||||
|
||||
NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange, object: persistentContainer.persistentStoreCoordinator)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [unowned self] _ in
|
||||
|
@ -177,7 +183,7 @@ class MastodonController: ObservableObject {
|
|||
_ = try await (ownAccount, ownInstance)
|
||||
|
||||
loadLists()
|
||||
async let _ = await loadFilters()
|
||||
_ = await loadFilters()
|
||||
} catch {
|
||||
Logging.general.error("MastodonController initialization failed: \(String(describing: error))")
|
||||
}
|
||||
|
@ -359,6 +365,23 @@ class MastodonController: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
private func loadCachedLists() -> [List] {
|
||||
let req = ListMO.fetchRequest()
|
||||
guard let lists = try? persistentContainer.viewContext.fetch(req) else {
|
||||
return []
|
||||
}
|
||||
return lists.map {
|
||||
List(id: $0.id, title: $0.title)
|
||||
}.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
|
||||
}
|
||||
|
||||
func getCachedList(id: String) -> List? {
|
||||
let req = ListMO.fetchRequest(id: id)
|
||||
return (try? persistentContainer.viewContext.fetch(req).first).flatMap {
|
||||
List(id: $0.id, title: $0.title)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func addedList(_ list: List) {
|
||||
var new = self.lists
|
||||
|
|
|
@ -11,13 +11,13 @@ import Pachyderm
|
|||
|
||||
@MainActor
|
||||
class RenameListService {
|
||||
private let list: List
|
||||
private let list: ListProtocol
|
||||
private let mastodonController: MastodonController
|
||||
private let present: (UIViewController) -> Void
|
||||
|
||||
private var renameAction: UIAlertAction?
|
||||
|
||||
init(list: List, mastodonController: MastodonController, present: @escaping (UIViewController) -> Void) {
|
||||
init(list: ListProtocol, mastodonController: MastodonController, present: @escaping (UIViewController) -> Void) {
|
||||
self.list = list
|
||||
self.mastodonController = mastodonController
|
||||
self.present = present
|
||||
|
@ -47,7 +47,7 @@ class RenameListService {
|
|||
|
||||
private func updateList(with title: String) async {
|
||||
do {
|
||||
let req = List.update(list, title: title)
|
||||
let req = List.update(list.id, title: title)
|
||||
let (list, _) = try await mastodonController.run(req)
|
||||
mastodonController.renamedList(list)
|
||||
} catch {
|
||||
|
|
|
@ -39,9 +39,9 @@ class OpenInSafariActivity: UIActivity {
|
|||
static func completionHandler(navigator: TuskerNavigationDelegate, url: URL) -> UIActivityViewController.CompletionWithItemsHandler {
|
||||
return { (activityType, _, _, _) in
|
||||
if activityType == .openInSafari {
|
||||
let vc = SFSafariViewController(url: url)
|
||||
vc.preferredControlTintColor = Preferences.shared.accentColor.color
|
||||
navigator.show(vc)
|
||||
MainActor.runUnsafely {
|
||||
navigator.selected(url: url, allowResolveStatuses: false, allowUniversalLinks: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,6 +26,10 @@ class StatusActivityItemSource: NSObject, UIActivityItemSource {
|
|||
}
|
||||
|
||||
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
|
||||
guard #unavailable(iOS 16.4) else {
|
||||
// iOS 16.4 shows the full content and attachments in the Messages preview, better than what we can generate with LPLinkMetadata
|
||||
return nil
|
||||
}
|
||||
let metadata = LPLinkMetadata()
|
||||
metadata.originalURL = status.url!
|
||||
metadata.url = status.url!
|
||||
|
|
|
@ -20,6 +20,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
configureSentry()
|
||||
swizzleStatusBar()
|
||||
swizzlePresentationController()
|
||||
|
||||
AppShortcutItem.createItems(for: application)
|
||||
|
||||
|
@ -66,13 +67,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
options.enableSwizzling = false
|
||||
// required to support releases/release health
|
||||
options.enableAutoSessionTracking = true
|
||||
options.enableOutOfMemoryTracking = false
|
||||
options.enableAutoPerformanceTracking = false
|
||||
options.enableWatchdogTerminationTracking = false
|
||||
options.enableAutoPerformanceTracing = false
|
||||
options.enableNetworkTracking = false
|
||||
options.enableAppHangTracking = false
|
||||
options.enableCoreDataTracking = false
|
||||
options.enableCoreDataTracing = false
|
||||
// we don't care about events like battery, keyboard show/hide
|
||||
options.enableAutoBreadcrumbTracking = false
|
||||
options.enableUserInteractionTracing = false
|
||||
|
||||
options.beforeSend = { event in
|
||||
// just no, why would anyone need this information
|
||||
|
@ -135,17 +137,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
var originalIMP: IMP?
|
||||
let imp = imp_implementationWithBlock({ (self: UIStatusBarManager, sender: AnyObject) in
|
||||
let original = unsafeBitCast(originalIMP!, to: (@convention(c) (UIStatusBarManager, Selector, AnyObject) -> Void).self)
|
||||
guard let windowScene = self.perform(Selector(("windowScene"))).takeUnretainedValue() as? UIWindowScene,
|
||||
let xPosition = sender.value(forKey: "xPosition") as? CGFloat,
|
||||
let delegate = windowScene.delegate as? TuskerSceneDelegate else {
|
||||
original(self, selector, sender)
|
||||
return
|
||||
let exception = catchNSException {
|
||||
guard let windowScene = self.perform(Selector(("windowScene"))).takeUnretainedValue() as? UIWindowScene,
|
||||
let xPosition = sender.value(forKey: "xPosition") as? CGFloat,
|
||||
let delegate = windowScene.delegate as? TuskerSceneDelegate else {
|
||||
original(self, selector, sender)
|
||||
return
|
||||
}
|
||||
switch delegate.handleStatusBarTapped(xPosition: xPosition) {
|
||||
case .stop:
|
||||
return
|
||||
case .continue:
|
||||
original(self, selector, sender)
|
||||
}
|
||||
}
|
||||
switch delegate.handleStatusBarTapped(xPosition: xPosition) {
|
||||
case .stop:
|
||||
return
|
||||
case .continue:
|
||||
original(self, selector, sender)
|
||||
if let exception {
|
||||
SentrySDK.capture(exception: exception)
|
||||
}
|
||||
} as @convention(block) (UIStatusBarManager, AnyObject) -> Void)
|
||||
originalIMP = class_replaceMethod(UIStatusBarManager.self, selector, imp, "v@:@")
|
||||
|
@ -153,4 +160,24 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
Logging.general.error("Unable to swizzle status bar manager")
|
||||
}
|
||||
}
|
||||
|
||||
private func swizzlePresentationController() {
|
||||
var originalIMP: IMP?
|
||||
let imp = imp_implementationWithBlock({ (self: UIPresentationController) in
|
||||
let new = UITraitCollection(pureBlackDarkMode: self.presentingViewController.traitCollection.pureBlackDarkMode)
|
||||
if let existing = self.overrideTraitCollection {
|
||||
self.overrideTraitCollection = UITraitCollection(traitsFrom: [existing, new])
|
||||
} else {
|
||||
self.overrideTraitCollection = new
|
||||
}
|
||||
let original = unsafeBitCast(originalIMP!, to: (@convention(c) (UIPresentationController) -> Void).self)
|
||||
original(self)
|
||||
} as (@convention(block) (UIPresentationController) -> Void))
|
||||
let sel = Selector(["Necessary", "If", "Traits", "update", "_"].reversed().joined())
|
||||
originalIMP = class_replaceMethod(UIPresentationController.self, sel, imp, "v@:")
|
||||
if originalIMP == nil {
|
||||
Logging.general.error("Unable to swizzle presentation controller")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -32,46 +32,38 @@ class ImageCache {
|
|||
}
|
||||
|
||||
func get(_ url: URL, loadOriginal: Bool = false, completion: ((Data?, UIImage?) -> Void)?) -> Request? {
|
||||
let key = url.absoluteString
|
||||
|
||||
let wrappedCompletion: ((Data?, UIImage?) -> Void)?
|
||||
if let completion = completion {
|
||||
wrappedCompletion = { (data, image) in
|
||||
if let image {
|
||||
if !loadOriginal,
|
||||
let size = self.desiredPixelSize {
|
||||
image.prepareThumbnail(of: size) {
|
||||
completion(data, $0)
|
||||
}
|
||||
} else {
|
||||
image.prepareForDisplay {
|
||||
completion(data, $0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
completion(data, image)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
wrappedCompletion = nil
|
||||
}
|
||||
|
||||
if !ImageCache.disableCaching,
|
||||
let entry = try? cache.get(key, loadOriginal: loadOriginal) {
|
||||
wrappedCompletion?(entry.data, entry.image)
|
||||
let entry = try? cache.get(url.absoluteString, loadOriginal: loadOriginal) {
|
||||
completion?(entry.data, entry.image)
|
||||
return nil
|
||||
} else {
|
||||
let task = dataTask(url: url, completion: wrappedCompletion)
|
||||
task.resume()
|
||||
return task
|
||||
return Task.detached(priority: .userInitiated) {
|
||||
let result = await self.fetch(url: url)
|
||||
switch result {
|
||||
case .data(let data):
|
||||
completion?(data, nil)
|
||||
case .dataAndImage(let data, let image):
|
||||
completion?(data, image)
|
||||
case .none:
|
||||
completion?(nil, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func get(_ url: URL, loadOriginal: Bool = false) async -> (Data?, UIImage?) {
|
||||
// todo: this should integrate with the task cancellation mechanism somehow
|
||||
return await withCheckedContinuation { continuation in
|
||||
_ = get(url, loadOriginal: loadOriginal) { data, image in
|
||||
continuation.resume(returning: (data, image))
|
||||
if !ImageCache.disableCaching,
|
||||
let entry = try? cache.get(url.absoluteString, loadOriginal: loadOriginal) {
|
||||
return (entry.data, entry.image)
|
||||
} else {
|
||||
let result = await self.fetch(url: url)
|
||||
switch result {
|
||||
case .data(let data):
|
||||
return (data, nil)
|
||||
case .dataAndImage(let data, let image):
|
||||
return (data, image)
|
||||
case .none:
|
||||
return (nil, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -81,21 +73,28 @@ class ImageCache {
|
|||
guard !ImageCache.disableCaching else { return }
|
||||
|
||||
if !((try? cache.has(url.absoluteString)) ?? false) {
|
||||
let task = dataTask(url: url, completion: nil)
|
||||
task.resume()
|
||||
Task.detached(priority: .medium) {
|
||||
_ = await self.fetch(url: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dataTask(url: URL, completion: ((Data?, UIImage?) -> Void)?) -> URLSessionDataTask {
|
||||
return URLSession.shared.dataTask(with: url) { data, response, error in
|
||||
guard error == nil,
|
||||
let data else {
|
||||
return
|
||||
}
|
||||
let image = UIImage(data: data)
|
||||
try? self.cache.set(url.absoluteString, data: data, image: image)
|
||||
completion?(data, image)
|
||||
private func fetch(url: URL) async -> FetchResult {
|
||||
guard let (data, _) = try? await URLSession.shared.data(from: url) else {
|
||||
return .none
|
||||
}
|
||||
guard let image = UIImage(data: data) else {
|
||||
try? cache.set(url.absoluteString, data: data, image: nil)
|
||||
return .data(data)
|
||||
}
|
||||
let preparedImage: UIImage?
|
||||
if let desiredPixelSize {
|
||||
preparedImage = await image.byPreparingThumbnail(ofSize: desiredPixelSize)
|
||||
} else {
|
||||
preparedImage = await image.byPreparingForDisplay()
|
||||
}
|
||||
try? cache.set(url.absoluteString, data: data, image: preparedImage ?? image)
|
||||
return .dataAndImage(data, preparedImage ?? image)
|
||||
}
|
||||
|
||||
func getData(_ url: URL) -> Data? {
|
||||
|
@ -114,6 +113,12 @@ class ImageCache {
|
|||
return cache.disk?.getSizeInBytes()
|
||||
}
|
||||
|
||||
typealias Request = URLSessionDataTask
|
||||
typealias Request = Task<Void, Never>
|
||||
|
||||
enum FetchResult {
|
||||
case data(Data)
|
||||
case dataAndImage(Data, UIImage)
|
||||
case none
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -77,6 +77,7 @@ class ImageDataCache {
|
|||
try? disk?.removeAll()
|
||||
}
|
||||
|
||||
// TODO: consider removing this and letting ImageCache just use the UIImage thumbnailing API
|
||||
private func scaleImageIfDesired(data: Data) -> UIImage? {
|
||||
guard let desiredPixelSize = desiredPixelSize,
|
||||
let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary) else {
|
||||
|
@ -84,14 +85,14 @@ class ImageDataCache {
|
|||
}
|
||||
|
||||
let maxDimension = max(desiredPixelSize.width, desiredPixelSize.height)
|
||||
let downsampleOptions = [
|
||||
let downsampleOptions: [CFString: Any] = [
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceShouldCacheImmediately: true,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: maxDimension
|
||||
] as CFDictionary
|
||||
]
|
||||
|
||||
if let downsampled = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions) {
|
||||
if let downsampled = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions as CFDictionary) {
|
||||
return UIImage(cgImage: downsampled)
|
||||
} else {
|
||||
return nil
|
||||
|
|
|
@ -25,7 +25,7 @@ public final class AccountPreferences: NSManagedObject {
|
|||
@NSManaged var pinnedTimelinesData: Data?
|
||||
|
||||
@LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: [])
|
||||
var pinnedTimelines: [Timeline]
|
||||
var pinnedTimelines: [PinnedTimeline]
|
||||
|
||||
static func `default`(account: LocalData.UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences {
|
||||
let prefs = AccountPreferences(context: context)
|
||||
|
|
|
@ -11,7 +11,7 @@ import CoreData
|
|||
import Pachyderm
|
||||
|
||||
@objc(ListMO)
|
||||
public final class ListMO: NSManagedObject {
|
||||
public final class ListMO: NSManagedObject, ListProtocol {
|
||||
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<ListMO> {
|
||||
return NSFetchRequest(entityName: "List")
|
||||
|
|
|
@ -211,7 +211,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
}
|
||||
]
|
||||
}
|
||||
SentrySDK.addBreadcrumb(crumb: crumb)
|
||||
SentrySDK.addBreadcrumb(crumb)
|
||||
fatalError("Unable to save managed object context: \(String(describing: error))")
|
||||
}
|
||||
}
|
||||
|
@ -545,6 +545,8 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
guard let timelinePosition = try? self.viewContext.existingObject(with: id) as? TimelinePosition else {
|
||||
continue
|
||||
}
|
||||
// the kvo observer that clears the LazilyDecoding cache doesn't always fire on remote changes, so do it manually
|
||||
timelinePosition.changedRemotely()
|
||||
NotificationCenter.default.post(name: .timelinePositionChanged, object: timelinePosition)
|
||||
}
|
||||
if changedAccountPrefs {
|
||||
|
|
|
@ -41,6 +41,10 @@ public final class TimelinePosition: NSManagedObject {
|
|||
self.createdAt = Date()
|
||||
}
|
||||
|
||||
func changedRemotely() {
|
||||
_statusIDs.removeCachedValue()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// blergh, this is the simplest way of getting the Timeline into a format that A) CoreData can handle and B) is usable in the predicate
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
//
|
||||
// MainActor+Unsafe.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 2/19/23.
|
||||
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/*
|
||||
Copied from https://github.com/ChimeHQ/ConcurrencyPlus/blob/fe3b3fd5436b196d8c5211ab2cc4b69fc35524fe/Sources/ConcurrencyPlus/MainActor%2BUnsafe.swift
|
||||
|
||||
Copyright (c) 2022, Chime
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
public extension MainActor {
|
||||
/// Execute the given body closure on the main actor without enforcing MainActor isolation.
|
||||
///
|
||||
/// This function exists to work around libraries with incorrect/inconsistent concurrency annotations. You should be **extremely** careful when using it, and only as a last resort.
|
||||
///
|
||||
/// It will crash if run on any non-main thread.
|
||||
@MainActor(unsafe)
|
||||
static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T {
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
|
||||
return try body()
|
||||
}
|
||||
}
|
|
@ -25,23 +25,4 @@ extension Timeline {
|
|||
}
|
||||
}
|
||||
|
||||
var image: UIImage {
|
||||
switch self {
|
||||
case .home:
|
||||
return UIImage(systemName: "house.fill")!
|
||||
case let .public(local):
|
||||
if local {
|
||||
return UIImage(systemName: "person.and.person.fill")!
|
||||
} else {
|
||||
return UIImage(systemName: "globe")!
|
||||
}
|
||||
case .list(id: _):
|
||||
return UIImage(systemName: "list.bullet")!
|
||||
case .tag(hashtag: _):
|
||||
return UIImage(systemName: "number")!
|
||||
case .direct:
|
||||
return UIImage(systemName: "enveloep.fill")!
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
//
|
||||
// View+AppListStyle.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 2/6/23.
|
||||
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func appGroupedListBackground(container: UIAppearanceContainer.Type, applyBackground: Bool = true) -> some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
if applyBackground {
|
||||
self
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.appGroupedBackground.edgesIgnoringSafeArea(.all))
|
||||
} else {
|
||||
self
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
} else {
|
||||
self
|
||||
.onAppear {
|
||||
UITableView.appearance(whenContainedInInstancesOf: [container]).backgroundColor = .appGroupedBackground
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appGroupedListRowBackground() -> some View {
|
||||
self.modifier(AppGroupedListRowBackground())
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppGroupedListRowBackground: ViewModifier {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if colorScheme == .dark, !Preferences.shared.pureBlackDarkMode {
|
||||
content
|
||||
.listRowBackground(Color.appGroupedCellBackground)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
|
@ -11,7 +11,7 @@ import Pachyderm
|
|||
import Combine
|
||||
|
||||
/// An opaque object that serves as the cache for the filtered-ness of a particular status.
|
||||
class FilterState {
|
||||
class FilterState: @unchecked Sendable {
|
||||
static var unknown: FilterState { FilterState(state: .unknown) }
|
||||
|
||||
fileprivate var state: State
|
||||
|
|
|
@ -55,7 +55,21 @@ struct HTMLConverter {
|
|||
case let node as Element:
|
||||
let attributed = NSMutableAttributedString(string: "", attributes: [.font: font, .foregroundColor: color])
|
||||
for child in node.getChildNodes() {
|
||||
var appendEllipsis = false
|
||||
if node.tagName() == "a",
|
||||
let el = child as? Element {
|
||||
if el.hasClass("invisible") {
|
||||
continue
|
||||
} else if el.hasClass("ellipsis") {
|
||||
appendEllipsis = true
|
||||
}
|
||||
}
|
||||
|
||||
attributed.append(attributedTextForHTMLNode(child, usePreformattedText: usePreformattedText || node.tagName() == "pre"))
|
||||
|
||||
if appendEllipsis {
|
||||
attributed.append(NSAttributedString("…"))
|
||||
}
|
||||
}
|
||||
|
||||
switch node.tagName() {
|
||||
|
@ -120,6 +134,8 @@ struct HTMLConverter {
|
|||
}
|
||||
|
||||
return attributed
|
||||
case is DataNode:
|
||||
return NSAttributedString()
|
||||
default:
|
||||
fatalError("Unexpected node type \(type(of: node))")
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ public struct LazilyDecoding<Enclosing: NSObject, Value: Codable> {
|
|||
private let fallback: Value
|
||||
private var value: Value?
|
||||
private var observation: NSKeyValueObservation?
|
||||
private var skipClearingOnNextUpdate = false
|
||||
|
||||
init(from keyPath: ReferenceWritableKeyPath<Enclosing, Data?>, fallback: Value) {
|
||||
self.keyPath = keyPath
|
||||
|
@ -37,13 +38,16 @@ public struct LazilyDecoding<Enclosing: NSObject, Value: Codable> {
|
|||
} else {
|
||||
guard let data = instance[keyPath: wrapper.keyPath] else { return wrapper.fallback }
|
||||
do {
|
||||
let value = try decoder.decode(Box<Value>.self, from: data)
|
||||
let value = try decoder.decode(Box.self, from: data)
|
||||
wrapper.value = value.value
|
||||
wrapper.observation = instance.observe(wrapper.keyPath, changeHandler: { instance, _ in
|
||||
var updated = instance[keyPath: storageKeyPath]
|
||||
updated.value = nil
|
||||
updated.observation = nil
|
||||
instance[keyPath: storageKeyPath] = updated
|
||||
var wrapper = instance[keyPath: storageKeyPath]
|
||||
if wrapper.skipClearingOnNextUpdate {
|
||||
wrapper.skipClearingOnNextUpdate = false
|
||||
} else {
|
||||
wrapper.removeCachedValue()
|
||||
}
|
||||
instance[keyPath: storageKeyPath] = wrapper
|
||||
})
|
||||
instance[keyPath: storageKeyPath] = wrapper
|
||||
return value.value
|
||||
|
@ -55,12 +59,18 @@ public struct LazilyDecoding<Enclosing: NSObject, Value: Codable> {
|
|||
set {
|
||||
var wrapper = instance[keyPath: storageKeyPath]
|
||||
wrapper.value = newValue
|
||||
wrapper.skipClearingOnNextUpdate = true
|
||||
instance[keyPath: storageKeyPath] = wrapper
|
||||
let newData = try! encoder.encode(Box(value: newValue))
|
||||
instance[keyPath: wrapper.keyPath] = newData
|
||||
}
|
||||
}
|
||||
|
||||
mutating func removeCachedValue() {
|
||||
value = nil
|
||||
observation = nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension LazilyDecoding {
|
||||
|
@ -72,7 +82,7 @@ extension LazilyDecoding {
|
|||
extension LazilyDecoding {
|
||||
// PropertyListEncoder only allows top-level types to be dicts or arrays, which breaks encoding nil-able values.
|
||||
// Wrapping everything in a Box ensures that it's always a dict.
|
||||
private struct Box<T: Codable>: Codable {
|
||||
let value: T
|
||||
struct Box: Codable {
|
||||
let value: Value
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ import UIKit
|
|||
struct MenuController {
|
||||
|
||||
static let composeCommand: UIKeyCommand = {
|
||||
return UIKeyCommand(title: "Compose", action: #selector(MainSplitViewController.presentCompose), input: "n", modifierFlags: .command)
|
||||
return UIKeyCommand(title: "Compose", action: #selector(MainSplitViewController.handleComposeKeyCommand), input: "n", modifierFlags: .command)
|
||||
}()
|
||||
|
||||
static func refreshCommand(discoverabilityTitle: String?) -> UIKeyCommand {
|
||||
|
|
|
@ -0,0 +1,129 @@
|
|||
//
|
||||
// PinnedTimeline.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 1/27/23.
|
||||
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
enum PinnedTimeline: Codable, Equatable, Hashable {
|
||||
case home
|
||||
case `public`(local: Bool)
|
||||
case tag(hashtag: String)
|
||||
case list(id: String)
|
||||
case instance(URL)
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let type = try container.decode(String.self, forKey: .type)
|
||||
switch type {
|
||||
case "home":
|
||||
self = .home
|
||||
case "public":
|
||||
self = .public(local: try container.decode(Bool.self, forKey: .local))
|
||||
case "tag":
|
||||
self = .tag(hashtag: try container.decode(String.self, forKey: .hashtag))
|
||||
case "list":
|
||||
self = .list(id: try container.decode(String.self, forKey: .listID))
|
||||
case "instance":
|
||||
self = .instance(try container.decode(URL.self, forKey: .instanceURL))
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(forKey: CodingKeys.type, in: container, debugDescription: "PinnedTimeline type must be one of 'home', 'local', 'tag', 'list', or 'instance'")
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case .home:
|
||||
try container.encode("home", forKey: .type)
|
||||
case .public(let local):
|
||||
try container.encode("public", forKey: .type)
|
||||
try container.encode(local, forKey: .local)
|
||||
case .tag(let hashtag):
|
||||
try container.encode("tag", forKey: .type)
|
||||
try container.encode(hashtag, forKey: .hashtag)
|
||||
case .list(let id):
|
||||
try container.encode("list", forKey: .type)
|
||||
try container.encode(id, forKey: .listID)
|
||||
case .instance(let url):
|
||||
try container.encode("instance", forKey: .type)
|
||||
try container.encode(url, forKey: .instanceURL)
|
||||
}
|
||||
}
|
||||
|
||||
init?(timeline: Timeline) {
|
||||
switch timeline {
|
||||
case .home:
|
||||
self = .home
|
||||
case .public(let local):
|
||||
self = .public(local: local)
|
||||
case .tag(let hashtag):
|
||||
self = .tag(hashtag: hashtag)
|
||||
case .list(let id):
|
||||
self = .list(id: id)
|
||||
case .direct:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var timeline: Timeline? {
|
||||
switch self {
|
||||
case .home:
|
||||
return .home
|
||||
case .public(let local):
|
||||
return .public(local: local)
|
||||
case .tag(let hashtag):
|
||||
return .tag(hashtag: hashtag)
|
||||
case .list(let id):
|
||||
return .list(id: id)
|
||||
case .instance(_):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .home:
|
||||
return "Home"
|
||||
case let .public(local):
|
||||
return local ? "Local" : "Federated"
|
||||
case let .tag(hashtag):
|
||||
return "#\(hashtag)"
|
||||
case .list:
|
||||
return "List"
|
||||
case .instance(let url):
|
||||
return url.host!
|
||||
}
|
||||
}
|
||||
|
||||
var image: UIImage {
|
||||
switch self {
|
||||
case .home:
|
||||
return UIImage(systemName: "house.fill")!
|
||||
case let .public(local):
|
||||
if local {
|
||||
return UIImage(systemName: "person.and.person.fill")!
|
||||
} else {
|
||||
return UIImage(systemName: "globe")!
|
||||
}
|
||||
case .list(id: _):
|
||||
return UIImage(systemName: "list.bullet")!
|
||||
case .tag(hashtag: _):
|
||||
return UIImage(systemName: "number")!
|
||||
case .instance(_):
|
||||
return UIImage(systemName: "globe")!
|
||||
}
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case local
|
||||
case hashtag
|
||||
case listID
|
||||
case instanceURL
|
||||
}
|
||||
}
|
|
@ -13,20 +13,24 @@ import os
|
|||
// to make the lock semantics more clear
|
||||
@available(iOS, obsoleted: 16.0)
|
||||
class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable> {
|
||||
private let lock: LockHolder<[AnyHashable: Any]>
|
||||
private let lock: any Lock<[Key: Value]>
|
||||
|
||||
init() {
|
||||
self.lock = LockHolder(initialState: [:])
|
||||
if #available(iOS 16.0, *) {
|
||||
self.lock = OSAllocatedUnfairLock(initialState: [:])
|
||||
} else {
|
||||
self.lock = UnfairLock(initialState: [:])
|
||||
}
|
||||
}
|
||||
|
||||
subscript(key: Key) -> Value? {
|
||||
get {
|
||||
return try! lock.withLock { dict in
|
||||
return lock.withLock { dict in
|
||||
dict[key]
|
||||
} as! Value?
|
||||
}
|
||||
}
|
||||
set(value) {
|
||||
_ = try! lock.withLock { dict in
|
||||
_ = lock.withLock { dict in
|
||||
dict[key] = value
|
||||
}
|
||||
}
|
||||
|
@ -34,40 +38,21 @@ class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable> {
|
|||
|
||||
/// If the result of this function is unused, it is preferable to use `removeValueWithoutReturning` as it executes asynchronously and doesn't block the calling thread.
|
||||
func removeValue(forKey key: Key) -> Value? {
|
||||
return try! lock.withLock { dict in
|
||||
return lock.withLock { dict in
|
||||
dict.removeValue(forKey: key)
|
||||
} as! Value?
|
||||
}
|
||||
}
|
||||
|
||||
func contains(key: Key) -> Bool {
|
||||
return try! lock.withLock { dict in
|
||||
return lock.withLock { dict in
|
||||
dict.keys.contains(key)
|
||||
} as! Bool
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: this should really be throws/rethrows but the stupid type-erased lock makes that not possible
|
||||
func withLock<R>(_ body: @Sendable (inout [Key: Value]) -> R) -> R where R: Sendable {
|
||||
return try! lock.withLock { dict in
|
||||
var downcasted = dict as! [Key: Value]
|
||||
defer { dict = downcasted }
|
||||
return body(&downcasted)
|
||||
} as! R
|
||||
}
|
||||
}
|
||||
|
||||
// this type erased struct is necessary due to a compiler bug with stored constrained existential types
|
||||
// see https://github.com/apple/swift/issues/61403
|
||||
// see #178
|
||||
fileprivate struct LockHolder<State> {
|
||||
let withLock: (_ body: @Sendable (inout State) throws -> any Sendable) throws -> any Sendable
|
||||
|
||||
init(initialState: State) {
|
||||
if #available(iOS 16.0, *) {
|
||||
let lock = OSAllocatedUnfairLock(initialState: initialState)
|
||||
self.withLock = lock.withLock(_:)
|
||||
} else {
|
||||
let lock = UnfairLock(initialState: initialState)
|
||||
self.withLock = lock.withLock(_:)
|
||||
func withLock<R>(_ body: @Sendable (inout [Key: Value]) throws -> R) rethrows -> R where R: Sendable {
|
||||
return try lock.withLock { dict in
|
||||
return try body(&dict)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,104 @@
|
|||
//
|
||||
// Colors.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 1/31/23.
|
||||
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
extension UIColor {
|
||||
static let appBackground = UIColor { traitCollection in
|
||||
if case .dark = traitCollection.userInterfaceStyle,
|
||||
!traitCollection.pureBlackDarkMode {
|
||||
return UIColor(hue: 230/360, saturation: 23/100, brightness: 10/100, alpha: 1)
|
||||
} else {
|
||||
return .systemBackground
|
||||
}
|
||||
}
|
||||
|
||||
static let appSecondaryBackground = UIColor { traitCollection in
|
||||
if case .dark = traitCollection.userInterfaceStyle,
|
||||
!traitCollection.pureBlackDarkMode {
|
||||
if traitCollection.userInterfaceLevel == .elevated {
|
||||
return UIColor(hue: 230/360, saturation: 23/100, brightness: 10/100, alpha: 1)
|
||||
} else {
|
||||
return UIColor(hue: 230/360, saturation: 23/100, brightness: 5/100, alpha: 1)
|
||||
}
|
||||
} else {
|
||||
return .secondarySystemBackground
|
||||
}
|
||||
}
|
||||
|
||||
static let appGroupedBackground = UIColor { traitCollection in
|
||||
if case .dark = traitCollection.userInterfaceStyle,
|
||||
!traitCollection.pureBlackDarkMode {
|
||||
return .appSecondaryBackground
|
||||
} else {
|
||||
return .systemGroupedBackground
|
||||
}
|
||||
}
|
||||
|
||||
static let appSelectedCellBackground = UIColor { traitCollection in
|
||||
if case .dark = traitCollection.userInterfaceStyle,
|
||||
!traitCollection.pureBlackDarkMode {
|
||||
return UIColor(hue: 230/360, saturation: 20/100, brightness: 27/100, alpha: 1)
|
||||
} else {
|
||||
return .systemFill
|
||||
}
|
||||
}
|
||||
|
||||
static let appGroupedCellBackground = UIColor { traitCollection in
|
||||
if case .dark = traitCollection.userInterfaceStyle {
|
||||
if traitCollection.pureBlackDarkMode {
|
||||
return .secondarySystemBackground
|
||||
} else {
|
||||
return .appFill
|
||||
}
|
||||
} else {
|
||||
return .systemBackground
|
||||
}
|
||||
}
|
||||
|
||||
static let appFill = UIColor { traitCollection in
|
||||
if case .dark = traitCollection.userInterfaceStyle,
|
||||
!traitCollection.pureBlackDarkMode {
|
||||
return UIColor(hue: 230/360, saturation: 20/100, brightness: 17/100, alpha: 1)
|
||||
} else {
|
||||
return .systemFill
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
static let appBackground = Color(uiColor: .appBackground)
|
||||
static let appGroupedBackground = Color(uiColor: .appGroupedBackground)
|
||||
static let appSecondaryBackground = Color(uiColor: .appSecondaryBackground)
|
||||
static let appSelectedCellBackground = Color(uiColor: .appGroupedCellBackground)
|
||||
static let appGroupedCellBackground = Color(uiColor: .appGroupedCellBackground)
|
||||
static let appFill = Color(uiColor: .appFill)
|
||||
}
|
||||
|
||||
private let traitsKey: String = ["Traits", "Defined", "client", "_"].reversed().joined()
|
||||
private let key = "tusker_usePureBlackDarkMode"
|
||||
|
||||
extension UITraitCollection {
|
||||
var pureBlackDarkMode: Bool {
|
||||
get {
|
||||
// default to true to mach OS behavior
|
||||
(value(forKey: traitsKey) as? [String: Any])?[key] as? Bool ?? true
|
||||
}
|
||||
set {
|
||||
var dict = value(forKey: traitsKey) as? [String: Any] ?? [:]
|
||||
dict[key] = newValue
|
||||
setValue(dict, forKey: traitsKey)
|
||||
}
|
||||
}
|
||||
|
||||
convenience init(pureBlackDarkMode: Bool) {
|
||||
self.init()
|
||||
self.pureBlackDarkMode = pureBlackDarkMode
|
||||
}
|
||||
}
|
|
@ -38,12 +38,14 @@ class Preferences: Codable, ObservableObject {
|
|||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme)
|
||||
self.pureBlackDarkMode = try container.decodeIfPresent(Bool.self, forKey: .pureBlackDarkMode) ?? true
|
||||
self.accentColor = try container.decodeIfPresent(AccentColor.self, forKey: .accentColor) ?? .default
|
||||
self.avatarStyle = try container.decode(AvatarStyle.self, forKey: .avatarStyle)
|
||||
self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames)
|
||||
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
|
||||
self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon)
|
||||
self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false
|
||||
self.showLinkPreviews = try container.decodeIfPresent(Bool.self, forKey: .showLinkPreviews) ?? true
|
||||
self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions
|
||||
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
|
||||
|
||||
|
@ -63,6 +65,7 @@ class Preferences: Codable, ObservableObject {
|
|||
self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true
|
||||
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
|
||||
self.showUncroppedMediaInline = try container.decodeIfPresent(Bool.self, forKey: .showUncroppedMediaInline) ?? true
|
||||
self.showAttachmentBadges = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentBadges) ?? true
|
||||
|
||||
self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
|
||||
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
|
||||
|
@ -72,6 +75,7 @@ class Preferences: Codable, ObservableObject {
|
|||
self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? []
|
||||
self.confirmBeforeReblog = try container.decodeIfPresent(Bool.self, forKey: .confirmBeforeReblog) ?? false
|
||||
self.timelineStateRestoration = try container.decodeIfPresent(Bool.self, forKey: .timelineStateRestoration) ?? true
|
||||
self.timelineSyncMode = try container.decodeIfPresent(TimelineSyncMode.self, forKey: .timelineSyncMode) ?? .icloud
|
||||
self.hideReblogsInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideReblogsInTimelines) ?? false
|
||||
self.hideRepliesInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideRepliesInTimelines) ?? false
|
||||
|
||||
|
@ -79,7 +83,7 @@ class Preferences: Codable, ObservableObject {
|
|||
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.hideDiscover = try container.decodeIfPresent(Bool.self, forKey: .hideDiscover) ?? false
|
||||
self.hideTrends = try container.decodeIfPresent(Bool.self, forKey: .hideTrends) ?? false
|
||||
|
||||
self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType)
|
||||
|
||||
|
@ -91,12 +95,14 @@ class Preferences: Codable, ObservableObject {
|
|||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encode(theme, forKey: .theme)
|
||||
try container.encode(pureBlackDarkMode, forKey: .pureBlackDarkMode)
|
||||
try container.encode(accentColor, forKey: .accentColor)
|
||||
try container.encode(avatarStyle, forKey: .avatarStyle)
|
||||
try container.encode(hideCustomEmojiInUsernames, forKey: .hideCustomEmojiInUsernames)
|
||||
try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon)
|
||||
try container.encode(alwaysShowStatusVisibilityIcon, forKey: .alwaysShowStatusVisibilityIcon)
|
||||
try container.encode(hideActionsInTimeline, forKey: .hideActionsInTimeline)
|
||||
try container.encode(showLinkPreviews, forKey: .showLinkPreviews)
|
||||
try container.encode(leadingStatusSwipeActions, forKey: .leadingStatusSwipeActions)
|
||||
try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions)
|
||||
|
||||
|
@ -112,6 +118,7 @@ class Preferences: Codable, ObservableObject {
|
|||
try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning)
|
||||
try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
|
||||
try container.encode(showUncroppedMediaInline, forKey: .showUncroppedMediaInline)
|
||||
try container.encode(showAttachmentBadges, forKey: .showAttachmentBadges)
|
||||
|
||||
try container.encode(openLinksInApps, forKey: .openLinksInApps)
|
||||
try container.encode(useInAppSafari, forKey: .useInAppSafari)
|
||||
|
@ -121,6 +128,7 @@ class Preferences: Codable, ObservableObject {
|
|||
try container.encode(oppositeCollapseKeywords, forKey: .oppositeCollapseKeywords)
|
||||
try container.encode(confirmBeforeReblog, forKey: .confirmBeforeReblog)
|
||||
try container.encode(timelineStateRestoration, forKey: .timelineStateRestoration)
|
||||
try container.encode(timelineSyncMode, forKey: .timelineSyncMode)
|
||||
try container.encode(hideReblogsInTimelines, forKey: .hideReblogsInTimelines)
|
||||
try container.encode(hideRepliesInTimelines, forKey: .hideRepliesInTimelines)
|
||||
|
||||
|
@ -128,7 +136,7 @@ class Preferences: Codable, ObservableObject {
|
|||
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
|
||||
try container.encode(grayscaleImages, forKey: .grayscaleImages)
|
||||
try container.encode(disableInfiniteScrolling, forKey: .disableInfiniteScrolling)
|
||||
try container.encode(hideDiscover, forKey: .hideDiscover)
|
||||
try container.encode(hideTrends, forKey: .hideTrends)
|
||||
|
||||
try container.encode(statusContentType, forKey: .statusContentType)
|
||||
|
||||
|
@ -138,12 +146,14 @@ class Preferences: Codable, ObservableObject {
|
|||
|
||||
// MARK: Appearance
|
||||
@Published var theme = UIUserInterfaceStyle.unspecified
|
||||
@Published var pureBlackDarkMode = true
|
||||
@Published var accentColor = AccentColor.default
|
||||
@Published var avatarStyle = AvatarStyle.roundRect
|
||||
@Published var hideCustomEmojiInUsernames = false
|
||||
@Published var showIsStatusReplyIcon = false
|
||||
@Published var alwaysShowStatusVisibilityIcon = false
|
||||
@Published var hideActionsInTimeline = false
|
||||
@Published var showLinkPreviews = true
|
||||
@Published var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog]
|
||||
@Published var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
|
||||
|
||||
|
@ -169,6 +179,7 @@ class Preferences: Codable, ObservableObject {
|
|||
@Published var blurMediaBehindContentWarning = true
|
||||
@Published var automaticallyPlayGifs = true
|
||||
@Published var showUncroppedMediaInline = true
|
||||
@Published var showAttachmentBadges = true
|
||||
|
||||
// MARK: Behavior
|
||||
@Published var openLinksInApps = true
|
||||
|
@ -179,6 +190,7 @@ class Preferences: Codable, ObservableObject {
|
|||
@Published var oppositeCollapseKeywords: [String] = []
|
||||
@Published var confirmBeforeReblog = false
|
||||
@Published var timelineStateRestoration = true
|
||||
@Published var timelineSyncMode = TimelineSyncMode.icloud
|
||||
@Published var hideReblogsInTimelines = false
|
||||
@Published var hideRepliesInTimelines = false
|
||||
|
||||
|
@ -187,7 +199,7 @@ class Preferences: Codable, ObservableObject {
|
|||
@Published var defaultNotificationsMode = NotificationsMode.allNotifications
|
||||
@Published var grayscaleImages = false
|
||||
@Published var disableInfiniteScrolling = false
|
||||
@Published var hideDiscover = false
|
||||
@Published var hideTrends = false
|
||||
|
||||
// MARK: Advanced
|
||||
@Published var statusContentType: StatusContentType = .plain
|
||||
|
@ -199,12 +211,14 @@ class Preferences: Codable, ObservableObject {
|
|||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case theme
|
||||
case pureBlackDarkMode
|
||||
case accentColor
|
||||
case avatarStyle
|
||||
case hideCustomEmojiInUsernames
|
||||
case showIsStatusReplyIcon
|
||||
case alwaysShowStatusVisibilityIcon
|
||||
case hideActionsInTimeline
|
||||
case showLinkPreviews
|
||||
case leadingStatusSwipeActions
|
||||
case trailingStatusSwipeActions
|
||||
|
||||
|
@ -221,6 +235,7 @@ class Preferences: Codable, ObservableObject {
|
|||
case blurMediaBehindContentWarning
|
||||
case automaticallyPlayGifs
|
||||
case showUncroppedMediaInline
|
||||
case showAttachmentBadges
|
||||
|
||||
case openLinksInApps
|
||||
case useInAppSafari
|
||||
|
@ -230,6 +245,7 @@ class Preferences: Codable, ObservableObject {
|
|||
case oppositeCollapseKeywords
|
||||
case confirmBeforeReblog
|
||||
case timelineStateRestoration
|
||||
case timelineSyncMode
|
||||
case hideReblogsInTimelines
|
||||
case hideRepliesInTimelines
|
||||
|
||||
|
@ -237,7 +253,7 @@ class Preferences: Codable, ObservableObject {
|
|||
case defaultNotificationsType
|
||||
case grayscaleImages
|
||||
case disableInfiniteScrolling
|
||||
case hideDiscover
|
||||
case hideTrends = "hideDiscover"
|
||||
|
||||
case statusContentType
|
||||
|
||||
|
@ -383,3 +399,10 @@ extension Preferences {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Preferences {
|
||||
enum TimelineSyncMode: String, Codable {
|
||||
case mastodon
|
||||
case icloud
|
||||
}
|
||||
}
|
||||
|
|
|
@ -128,7 +128,9 @@ private func createReblogAction(status: StatusMO, container: StatusSwipeActionCo
|
|||
|
||||
private func createShareAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction {
|
||||
let action = UIContextualAction(style: .normal, title: "Share") { [unowned container] _, _, completion in
|
||||
container.navigationDelegate.showMoreOptions(forStatus: status.id, source: .view(container))
|
||||
MainActor.runUnsafely {
|
||||
container.navigationDelegate.showMoreOptions(forStatus: status.id, source: .view(container))
|
||||
}
|
||||
completion(true)
|
||||
}
|
||||
// bold to more closesly match other action symbols
|
||||
|
@ -166,7 +168,9 @@ private func createBookmarkAction(status: StatusMO, container: StatusSwipeAction
|
|||
|
||||
private func createOpenInSafariAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction {
|
||||
let action = UIContextualAction(style: .normal, title: "Open in Safari") { [unowned container] _, _, completion in
|
||||
container.navigationDelegate.selected(url: status.url!, allowUniversalLinks: false)
|
||||
MainActor.runUnsafely {
|
||||
container.navigationDelegate.selected(url: status.url!, allowUniversalLinks: false)
|
||||
}
|
||||
completion(true)
|
||||
}
|
||||
action.image = UIImage(systemName: "safari")
|
||||
|
|
|
@ -49,7 +49,7 @@ class SavedDataManager: Codable {
|
|||
var changed = false
|
||||
|
||||
if let hashtags = savedHashtags[accountID] {
|
||||
let objects = hashtags.map {
|
||||
let objects: [[String: Any]] = hashtags.map {
|
||||
["url": $0.url, "name": $0.name]
|
||||
}
|
||||
let hashtagsReq = NSBatchInsertRequest(entity: SavedHashtag.entity(), objects: objects)
|
||||
|
|
|
@ -9,10 +9,14 @@
|
|||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
var rootViewController: TuskerRootViewController? {
|
||||
window?.rootViewController as? TuskerRootViewController
|
||||
}
|
||||
|
||||
private var launchActivity: NSUserActivity?
|
||||
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||
|
@ -70,7 +74,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
private func viewController(for activity: NSUserActivity, mastodonController: MastodonController) -> UIViewController? {
|
||||
switch UserActivityType(rawValue: activity.activityType) {
|
||||
case .showTimeline:
|
||||
guard let timeline = UserActivityManager.getTimeline(from: activity) else { return nil }
|
||||
guard let (timeline, _) = UserActivityManager.getTimeline(from: activity) else { return nil }
|
||||
return timelineViewController(for: timeline, mastodonController: mastodonController)
|
||||
|
||||
case .showConversation:
|
||||
|
@ -82,10 +86,10 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
return NotificationsPageViewController(initialMode: mode, mastodonController: mastodonController)
|
||||
|
||||
case .search:
|
||||
return SearchViewController(mastodonController: mastodonController)
|
||||
return InlineTrendsViewController(mastodonController: mastodonController)
|
||||
|
||||
case .bookmarks:
|
||||
return BookmarksTableViewController(mastodonController: mastodonController)
|
||||
return BookmarksViewController(mastodonController: mastodonController)
|
||||
|
||||
case .myProfile:
|
||||
return MyProfileViewController(mastodonController: mastodonController)
|
||||
|
@ -112,7 +116,6 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
}
|
||||
|
||||
@objc private func themePrefChanged() {
|
||||
window?.overrideUserInterfaceStyle = Preferences.shared.theme
|
||||
window?.tintColor = Preferences.shared.accentColor.color
|
||||
applyAppearancePreferences()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,10 +9,12 @@
|
|||
import UIKit
|
||||
import Combine
|
||||
|
||||
class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
var rootViewController: TuskerRootViewController? { nil }
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||
|
@ -100,8 +102,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
}
|
||||
|
||||
@objc private func themePrefChanged() {
|
||||
window?.overrideUserInterfaceStyle = Preferences.shared.theme
|
||||
window?.tintColor = Preferences.shared.accentColor.color
|
||||
applyAppearancePreferences()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -64,7 +64,15 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
|
||||
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||
stateRestorationLogger.info("MainSceneDelegate.scene(_:continue:) called with \(userActivity.activityType, privacy: .public)")
|
||||
_ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene))
|
||||
let context: any UserActivityHandlingContext
|
||||
if let account = UserActivityManager.getAccount(from: userActivity),
|
||||
account.id != scene.session.mastodonController!.accountInfo!.id {
|
||||
stateRestorationLogger.info("MainSceneDelegate cannot resume user activity for different account")
|
||||
return
|
||||
} else {
|
||||
context = ActiveAccountUserActivityHandlingContext(isHandoff: true, root: rootViewController!)
|
||||
}
|
||||
_ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context))
|
||||
}
|
||||
|
||||
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
|
||||
|
@ -169,10 +177,16 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
activateAccount(account, animated: false)
|
||||
|
||||
if let activity = launchActivity {
|
||||
func doRestoreActivity(context: UserActivityHandlingContext) {
|
||||
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
|
||||
context.finalize(activity: activity)
|
||||
}
|
||||
if activity.isStateRestorationActivity {
|
||||
rootViewController?.restoreActivity(activity)
|
||||
doRestoreActivity(context: StateRestorationUserActivityHandlingContext(root: rootViewController!))
|
||||
} else if activity.activityType != UserActivityType.mainScene.rawValue {
|
||||
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!))
|
||||
doRestoreActivity(context: ActiveAccountUserActivityHandlingContext(isHandoff: false, root: rootViewController!))
|
||||
} else {
|
||||
stateRestorationLogger.fault("MainSceneDelegate launched with non-restorable activity \(activity.activityType, privacy: .public)")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
@ -204,9 +218,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
} else {
|
||||
direction = .none
|
||||
}
|
||||
container.setRoot(newRoot, animating: direction)
|
||||
container.setRoot(newRoot, for: account, animating: direction)
|
||||
} else {
|
||||
window!.rootViewController = AccountSwitchingContainerViewController(root: newRoot)
|
||||
window!.rootViewController = AccountSwitchingContainerViewController(root: newRoot, for: account)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -214,7 +228,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
guard let account = window?.windowScene?.session.mastodonController?.accountInfo else {
|
||||
return
|
||||
}
|
||||
LocalData.shared.removeAccount(account)
|
||||
LogoutService(accountInfo: account).run()
|
||||
if LocalData.shared.onboardingComplete {
|
||||
activateAccount(LocalData.shared.accounts.first!, animated: false)
|
||||
} else {
|
||||
|
@ -243,8 +257,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
}
|
||||
|
||||
@objc func themePrefChanged() {
|
||||
window?.overrideUserInterfaceStyle = Preferences.shared.theme
|
||||
window?.tintColor = Preferences.shared.accentColor.color
|
||||
applyAppearancePreferences()
|
||||
}
|
||||
|
||||
func showAddAccount() {
|
||||
|
|
|
@ -7,11 +7,11 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import Sentry
|
||||
|
||||
protocol TuskerSceneDelegate: UISceneDelegate {
|
||||
var window: UIWindow? { get }
|
||||
var rootViewController: TuskerRootViewController? { get }
|
||||
|
||||
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult
|
||||
}
|
||||
|
||||
enum StatusBarTapActionResult {
|
||||
|
@ -27,4 +27,19 @@ extension TuskerSceneDelegate {
|
|||
}
|
||||
return .continue
|
||||
}
|
||||
|
||||
func applyAppearancePreferences() {
|
||||
guard let window else { return }
|
||||
window.overrideUserInterfaceStyle = Preferences.shared.theme
|
||||
window.tintColor = Preferences.shared.accentColor.color
|
||||
let exception = catchNSException {
|
||||
let key = ["Controller", "Presentation", "root", "_"].reversed().joined()
|
||||
if let rootPresentationController = window.value(forKey: key) as? UIPresentationController {
|
||||
rootPresentationController.overrideTraitCollection = UITraitCollection(pureBlackDarkMode: Preferences.shared.pureBlackDarkMode)
|
||||
}
|
||||
}
|
||||
if let exception {
|
||||
SentrySDK.capture(exception: exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,8 @@ import Pachyderm
|
|||
|
||||
class AccountFollowsListViewController: UIViewController, CollectionViewController {
|
||||
|
||||
private static let pageSize = 40
|
||||
|
||||
let accountID: String
|
||||
let mastodonController: MastodonController
|
||||
let mode: AccountFollowsViewController.Mode
|
||||
|
@ -39,7 +41,8 @@ class AccountFollowsListViewController: UIViewController, CollectionViewControll
|
|||
}
|
||||
|
||||
override func loadView() {
|
||||
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
|
||||
var config = UICollectionLayoutListConfiguration(appearance: .plain)
|
||||
config.backgroundColor = .appBackground
|
||||
config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in
|
||||
guard let item = self.dataSource.itemIdentifier(for: indexPath) else {
|
||||
return sectionConfig
|
||||
|
@ -63,6 +66,16 @@ class AccountFollowsListViewController: UIViewController, CollectionViewControll
|
|||
let accountCell = UICollectionView.CellRegistration<AccountCollectionViewCell, String> { [unowned self] cell, indexPath, item in
|
||||
cell.delegate = self
|
||||
cell.updateUI(accountID: item)
|
||||
|
||||
cell.configurationUpdateHandler = { cell, state in
|
||||
var config = UIBackgroundConfiguration.listPlainCell().updated(for: state)
|
||||
if state.isHighlighted || state.isSelected {
|
||||
config.backgroundColor = .appSelectedCellBackground
|
||||
} else {
|
||||
config.backgroundColor = .appBackground
|
||||
}
|
||||
cell.backgroundConfiguration = config
|
||||
}
|
||||
}
|
||||
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, indexPath, item in
|
||||
cell.indicator.startAnimating()
|
||||
|
@ -89,12 +102,12 @@ class AccountFollowsListViewController: UIViewController, CollectionViewControll
|
|||
}
|
||||
}
|
||||
|
||||
private func request(for range: RequestRange) -> Request<[Account]> {
|
||||
private nonisolated func request(for range: RequestRange) -> Request<[Account]> {
|
||||
switch mode {
|
||||
case .following:
|
||||
return Account.getFollowing(accountID, range: range)
|
||||
return Account.getFollowing(accountID, range: range.withCount(Self.pageSize))
|
||||
case .followers:
|
||||
return Account.getFollowers(accountID, range: range)
|
||||
return Account.getFollowers(accountID, range: range.withCount(Self.pageSize))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -31,7 +31,8 @@ class AccountListViewController: UIViewController, CollectionViewController {
|
|||
}
|
||||
|
||||
override func loadView() {
|
||||
let config = UICollectionLayoutListConfiguration(appearance: .grouped)
|
||||
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
|
||||
config.backgroundColor = .appGroupedBackground
|
||||
let layout = UICollectionViewCompositionalLayout.list(using: config)
|
||||
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
collectionView.delegate = self
|
||||
|
|
|
@ -72,7 +72,8 @@ class AssetCollectionViewController: UIViewController, UICollectionViewDelegate
|
|||
// bottom ignores safe area because we want cells to underflow bottom of the screen on notched iPhones
|
||||
view.bottomAnchor.constraint(equalTo: collectionView.bottomAnchor),
|
||||
])
|
||||
view.backgroundColor = .systemBackground
|
||||
view.backgroundColor = .appBackground
|
||||
collectionView.backgroundColor = .appBackground
|
||||
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(donePressed))
|
||||
|
||||
|
|
|
@ -34,6 +34,7 @@ class AssetCollectionsListViewController: UITableViewController {
|
|||
tableView.register(UINib(nibName: "AlbumTableViewCell", bundle: .main), forCellReuseIdentifier: "albumCell")
|
||||
|
||||
tableView.allowsFocus = true
|
||||
tableView.backgroundColor = .appGroupedBackground
|
||||
|
||||
dataSource = DataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
|
||||
switch item {
|
||||
|
|
|
@ -1,196 +0,0 @@
|
|||
//
|
||||
// BookmarksTableViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 12/15/19.
|
||||
// Copyright © 2019 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
class BookmarksTableViewController: EnhancedTableViewController {
|
||||
|
||||
private let statusCell = "statusCell"
|
||||
|
||||
let mastodonController: MastodonController
|
||||
|
||||
private var loaded = false
|
||||
|
||||
var statuses: [(id: String, state: CollapseState)] = []
|
||||
|
||||
var newer: RequestRange?
|
||||
var older: RequestRange?
|
||||
|
||||
init(mastodonController: MastodonController) {
|
||||
self.mastodonController = mastodonController
|
||||
|
||||
super.init(style: .plain)
|
||||
|
||||
dragEnabled = true
|
||||
|
||||
title = NSLocalizedString("Bookmarks", comment: "bookmarks screen title")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 140
|
||||
tableView.allowsFocus = true
|
||||
|
||||
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: statusCell)
|
||||
|
||||
tableView.prefetchDataSource = self
|
||||
|
||||
userActivity = UserActivityManager.bookmarksActivity()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
if !loaded {
|
||||
loaded = true
|
||||
|
||||
let request = Client.getBookmarks()
|
||||
mastodonController.run(request) { (response) in
|
||||
guard case let .success(statuses, pagination) = response else { fatalError() }
|
||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
self.statuses.append(contentsOf: statuses.map { ($0.id, .unknown) })
|
||||
self.newer = pagination?.newer
|
||||
self.older = pagination?.older
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.tableView.reloadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Table view data source
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return 1
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return statuses.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: statusCell, for: indexPath) as! TimelineStatusTableViewCell
|
||||
cell.delegate = self
|
||||
let (id, state) = statuses[indexPath.row]
|
||||
cell.updateUI(statusID: id, state: state)
|
||||
return cell
|
||||
}
|
||||
|
||||
// MARK: - Table view delegate
|
||||
|
||||
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard indexPath.row == statuses.count, let older = older else {
|
||||
return
|
||||
}
|
||||
|
||||
let request = Client.getBookmarks(range: older)
|
||||
mastodonController.run(request) { (response) in
|
||||
guard case let .success(newStatuses, pagination) = response else { fatalError() }
|
||||
self.older = pagination?.older
|
||||
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
|
||||
let newIndexPaths = (self.statuses.count..<(self.statuses.count + newStatuses.count)).map {
|
||||
IndexPath(row: $0, section: 0)
|
||||
}
|
||||
self.statuses.append(contentsOf: newStatuses.map { ($0.id, .unknown) })
|
||||
|
||||
DispatchQueue.main.async {
|
||||
UIView.performWithoutAnimation {
|
||||
self.tableView.insertRows(at: newIndexPaths, with: .automatic)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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? {
|
||||
let cellConfig = (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
|
||||
|
||||
guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else {
|
||||
return cellConfig
|
||||
}
|
||||
|
||||
let unbookmarkAction = UIContextualAction(style: .destructive, title: NSLocalizedString("Unbookmark", comment: "unbookmark action title")) { (action, view, completion) in
|
||||
let request = Status.unbookmark(status.id)
|
||||
self.mastodonController.run(request) { (response) in
|
||||
guard case let .success(newStatus, _) = response else { fatalError() }
|
||||
self.mastodonController.persistentContainer.addOrUpdate(status: newStatus)
|
||||
self.statuses.remove(at: indexPath.row)
|
||||
}
|
||||
}
|
||||
unbookmarkAction.image = UIImage(systemName: "bookmark.fill")
|
||||
|
||||
let config: UISwipeActionsConfiguration
|
||||
if let cellConfig = cellConfig {
|
||||
config = UISwipeActionsConfiguration(actions: cellConfig.actions + [unbookmarkAction])
|
||||
config.performsFirstActionWithFullSwipe = cellConfig.performsFirstActionWithFullSwipe
|
||||
} else {
|
||||
config = UISwipeActionsConfiguration(actions: [unbookmarkAction])
|
||||
config.performsFirstActionWithFullSwipe = false
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
|
||||
guard let userInfo = notification.userInfo,
|
||||
let accountID = mastodonController.accountInfo?.id,
|
||||
userInfo["accountID"] as? String == accountID,
|
||||
let statusIDs = userInfo["statusIDs"] as? [String] else {
|
||||
return
|
||||
}
|
||||
let indicesToDelete = statusIDs
|
||||
.compactMap { id in
|
||||
self.statuses.firstIndex(where: { $0.id == id })
|
||||
}
|
||||
self.statuses.remove(atOffsets: IndexSet(indicesToDelete))
|
||||
self.tableView.deleteRows(at: indicesToDelete.map { IndexPath(row: $0, section: 0) }, with: .automatic)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension BookmarksTableViewController: TuskerNavigationDelegate {
|
||||
var apiController: MastodonController! { mastodonController }
|
||||
}
|
||||
|
||||
extension BookmarksTableViewController: ToastableViewController {
|
||||
}
|
||||
|
||||
extension BookmarksTableViewController: MenuActionProvider {
|
||||
}
|
||||
|
||||
extension BookmarksTableViewController: StatusTableViewCellDelegate {
|
||||
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
|
||||
tableView.beginUpdates()
|
||||
tableView.endUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
extension BookmarksTableViewController: UITableViewDataSourcePrefetching, StatusTablePrefetching {
|
||||
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||
let ids = indexPaths.map { statuses[$0.row].id }
|
||||
prefetchStatuses(with: ids)
|
||||
}
|
||||
}
|
|
@ -112,7 +112,7 @@ struct ComposeAttachmentsList: View {
|
|||
self.isShowingAssetPickerPopover = false
|
||||
}
|
||||
// on iPadOS 16, this is necessary to show the dark color in the popover arrow
|
||||
.background(Color(.systemBackground))
|
||||
.background(Color(.appBackground))
|
||||
.environment(\.colorScheme, .dark)
|
||||
.edgesIgnoringSafeArea(.bottom)
|
||||
.withSheetDetentsIfAvailable()
|
||||
|
|
|
@ -236,6 +236,7 @@ struct ComposeAutocompleteEmojisView: View {
|
|||
CustomEmojiImageView(emoji: emoji)
|
||||
.frame(height: emojiSize)
|
||||
}
|
||||
.accessibilityLabel(emoji.shortcode)
|
||||
}
|
||||
} header: {
|
||||
if !section.isEmpty {
|
||||
|
@ -271,6 +272,7 @@ struct ComposeAutocompleteEmojisView: View {
|
|||
.foregroundColor(Color(UIColor.label))
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(emoji.shortcode)
|
||||
.frame(height: emojiSize)
|
||||
}
|
||||
.animation(.linear(duration: 0.2), value: emojis)
|
||||
|
@ -293,6 +295,7 @@ struct ComposeAutocompleteEmojisView: View {
|
|||
.aspectRatio(contentMode: .fit)
|
||||
.rotationEffect(expanded ? .zero : .degrees(180))
|
||||
}
|
||||
.accessibilityLabel(expanded ? "Collapse" : "Expand")
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
|
||||
|
|
|
@ -15,15 +15,17 @@ struct ComposeEmojiTextField: UIViewRepresentable {
|
|||
|
||||
@Binding var text: String
|
||||
let placeholder: String
|
||||
let maxLength: Int?
|
||||
let becomeFirstResponder: Binding<Bool>?
|
||||
let focusNextView: Binding<Bool>?
|
||||
private var didChange: ((String) -> Void)? = nil
|
||||
private var didEndEditing: (() -> Void)? = nil
|
||||
private var backgroundColor: UIColor? = nil
|
||||
|
||||
init(text: Binding<String>, placeholder: String, becomeFirstResponder: Binding<Bool>? = nil, focusNextView: Binding<Bool>? = nil) {
|
||||
init(text: Binding<String>, placeholder: String, maxLength: Int? = nil, becomeFirstResponder: Binding<Bool>? = nil, focusNextView: Binding<Bool>? = nil) {
|
||||
self._text = text
|
||||
self.placeholder = placeholder
|
||||
self.maxLength = maxLength
|
||||
self.becomeFirstResponder = becomeFirstResponder
|
||||
self.focusNextView = focusNextView
|
||||
self.didChange = nil
|
||||
|
@ -74,6 +76,7 @@ struct ComposeEmojiTextField: UIViewRepresentable {
|
|||
} else {
|
||||
uiView.text = text
|
||||
}
|
||||
context.coordinator.maxLength = maxLength
|
||||
context.coordinator.didChange = didChange
|
||||
context.coordinator.didEndEditing = didEndEditing
|
||||
context.coordinator.focusNextView = focusNextView
|
||||
|
@ -95,6 +98,7 @@ struct ComposeEmojiTextField: UIViewRepresentable {
|
|||
var text: Binding<String>!
|
||||
// break retained cycle through ComposeUIState.currentInput
|
||||
unowned var uiState: ComposeUIState!
|
||||
var maxLength: Int?
|
||||
var didChange: ((String) -> Void)?
|
||||
var didEndEditing: (() -> Void)?
|
||||
var focusNextView: Binding<Bool>?
|
||||
|
@ -114,6 +118,14 @@ struct ComposeEmojiTextField: UIViewRepresentable {
|
|||
focusNextView?.wrappedValue = true
|
||||
}
|
||||
|
||||
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
||||
if let maxLength {
|
||||
return ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string).count <= maxLength
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func textFieldDidBeginEditing(_ textField: UITextField) {
|
||||
uiState.currentInput = self
|
||||
updateAutocompleteState(textField: textField)
|
||||
|
|
|
@ -20,6 +20,7 @@ struct ComposePollView: View {
|
|||
@ObservedObject var draft: Draft
|
||||
@ObservedObject var poll: Draft.Poll
|
||||
|
||||
@EnvironmentObject var mastodonController: MastodonController
|
||||
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
||||
|
||||
@State private var duration: Duration
|
||||
|
@ -31,6 +32,14 @@ struct ComposePollView: View {
|
|||
self._duration = State(initialValue: .fromTimeInterval(poll.duration) ?? .oneDay)
|
||||
}
|
||||
|
||||
private var canAddOption: Bool {
|
||||
if let pollConfig = mastodonController.instance?.pollsConfiguration {
|
||||
return poll.options.count < pollConfig.maxOptions
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
|
@ -67,9 +76,15 @@ struct ComposePollView: View {
|
|||
.frame(height: 44 * CGFloat(poll.options.count))
|
||||
|
||||
Button(action: self.addOption) {
|
||||
Label("Add Option", systemImage: "plus")
|
||||
Label {
|
||||
Text("Add Option")
|
||||
} icon: {
|
||||
Image(systemName: "plus")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(!canAddOption)
|
||||
|
||||
HStack {
|
||||
MenuPicker(selection: $poll.multiple, options: [
|
||||
|
@ -96,7 +111,7 @@ struct ComposePollView: View {
|
|||
|
||||
private var backgroundColor: Color {
|
||||
// in light mode, .secondarySystemBackground has a blue-ish hue, which we don't want
|
||||
colorScheme == .dark ? Color(UIColor.secondarySystemBackground) : Color(white: 0.95)
|
||||
colorScheme == .dark ? Color.appFill : Color(white: 0.95)
|
||||
}
|
||||
|
||||
private var buttonBackgroundColor: Color {
|
||||
|
@ -155,6 +170,8 @@ struct ComposePollOption: View {
|
|||
@ObservedObject var option: Draft.Poll.Option
|
||||
let optionIndex: Int
|
||||
|
||||
@EnvironmentObject private var mastodonController: MastodonController
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Checkbox(radiusFraction: poll.multiple ? 0.1 : 0.5, borderWidth: 2)
|
||||
|
@ -173,8 +190,8 @@ struct ComposePollOption: View {
|
|||
}
|
||||
|
||||
private var textField: some View {
|
||||
var field = ComposeEmojiTextField(text: $option.text, placeholder: "Option \(optionIndex + 1)")
|
||||
return field.backgroundColor(.systemBackground)
|
||||
var field = ComposeEmojiTextField(text: $option.text, placeholder: "Option \(optionIndex + 1)", maxLength: mastodonController.instance?.pollsConfiguration?.maxCharactersPerOption)
|
||||
return field.backgroundColor(.appBackground)
|
||||
}
|
||||
|
||||
private func removeOption() {
|
||||
|
@ -199,7 +216,7 @@ struct ComposePollOption: View {
|
|||
.cornerRadius(radiusFraction * size)
|
||||
|
||||
Rectangle()
|
||||
.foregroundColor(Color(UIColor.systemBackground))
|
||||
.foregroundColor(Color(UIColor.appBackground))
|
||||
.frame(width: innerSize, height: innerSize)
|
||||
.cornerRadius(radiusFraction * innerSize)
|
||||
}
|
||||
|
|
|
@ -54,6 +54,7 @@ struct ComposeToolbar: View {
|
|||
.font(.system(size: imageSize))
|
||||
.padding(5)
|
||||
.hoverEffect()
|
||||
.transition(.opacity.animation(.linear(duration: 0.2)))
|
||||
}
|
||||
|
||||
if let currentInput = uiState.currentInput,
|
||||
|
@ -74,16 +75,11 @@ struct ComposeToolbar: View {
|
|||
.accessibilityLabel(format.accessibilityLabel)
|
||||
.padding(5)
|
||||
.hoverEffect()
|
||||
.transition(.opacity.animation(.linear(duration: 0.2)))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: self.draftsButtonPressed) {
|
||||
Text("Drafts")
|
||||
}
|
||||
.padding(5)
|
||||
.hoverEffect()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(minWidth: minWidth)
|
||||
|
@ -119,10 +115,6 @@ struct ComposeToolbar: View {
|
|||
uiState.currentInput?.beginAutocompletingEmoji()
|
||||
}
|
||||
|
||||
private func draftsButtonPressed() {
|
||||
uiState.isShowingDraftsList = true
|
||||
}
|
||||
|
||||
private func formatAction(_ format: StatusFormat) -> () -> Void {
|
||||
{
|
||||
uiState.currentInput?.applyFormat(format)
|
||||
|
|
|
@ -94,6 +94,10 @@ struct ComposeView: View {
|
|||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
// just using .background doesn't work; for some reason it gets inset immediately after the software keyboard is dismissed
|
||||
Color.appBackground
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
|
||||
mainList
|
||||
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||
|
||||
|
@ -124,7 +128,7 @@ struct ComposeView: View {
|
|||
}
|
||||
})
|
||||
.sheet(isPresented: $uiState.isShowingDraftsList) {
|
||||
DraftsView(currentDraft: draft, mastodonController: mastodonController)
|
||||
DraftsRepresentable(currentDraft: draft, mastodonController: mastodonController)
|
||||
}
|
||||
.actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet)
|
||||
.alert(isPresented: $isShowingPostErrorAlert) {
|
||||
|
@ -169,11 +173,13 @@ struct ComposeView: View {
|
|||
)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.appBackground)
|
||||
}
|
||||
|
||||
header
|
||||
.listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.appBackground)
|
||||
|
||||
if uiState.draft.contentWarningEnabled {
|
||||
ComposeEmojiTextField(
|
||||
|
@ -184,6 +190,7 @@ struct ComposeView: View {
|
|||
)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.appBackground)
|
||||
}
|
||||
|
||||
MainComposeTextView(
|
||||
|
@ -192,17 +199,20 @@ struct ComposeView: View {
|
|||
)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.appBackground)
|
||||
|
||||
if let poll = draft.poll {
|
||||
ComposePollView(draft: draft, poll: poll)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.appBackground)
|
||||
}
|
||||
|
||||
ComposeAttachmentsList(
|
||||
draft: draft
|
||||
)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
|
||||
.listRowBackground(Color.appBackground)
|
||||
}
|
||||
.animation(.default, value: draft.poll?.options.count)
|
||||
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||
|
@ -239,16 +249,25 @@ struct ComposeView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var postButton: some View {
|
||||
Button {
|
||||
Task {
|
||||
await self.postStatus()
|
||||
if draft.hasContent {
|
||||
Button {
|
||||
Task {
|
||||
await self.postStatus()
|
||||
}
|
||||
} label: {
|
||||
Text("Post")
|
||||
}
|
||||
.keyboardShortcut(.return, modifiers: .command)
|
||||
.disabled(!postButtonEnabled)
|
||||
} else {
|
||||
Button {
|
||||
uiState.isShowingDraftsList = true
|
||||
} label: {
|
||||
Text("Drafts")
|
||||
}
|
||||
} label: {
|
||||
Text("Post")
|
||||
}
|
||||
.keyboardShortcut(.return, modifiers: .command)
|
||||
.disabled(!postButtonEnabled)
|
||||
}
|
||||
|
||||
private func cancel() {
|
||||
|
@ -310,7 +329,7 @@ struct ComposeView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
extension View {
|
||||
@available(iOS, obsoleted: 16.0)
|
||||
@ViewBuilder
|
||||
func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
|
||||
|
|
|
@ -8,6 +8,21 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS, obsoleted: 16.0)
|
||||
struct DraftsRepresentable: UIViewControllerRepresentable {
|
||||
typealias UIViewControllerType = UIHostingController<DraftsView>
|
||||
|
||||
let currentDraft: Draft
|
||||
let mastodonController: MastodonController
|
||||
|
||||
func makeUIViewController(context: Context) -> UIHostingController<DraftsView> {
|
||||
return UIHostingController(rootView: DraftsView(currentDraft: currentDraft, mastodonController: mastodonController))
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIHostingController<DraftsView>, context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
struct DraftsView: View {
|
||||
let currentDraft: Draft
|
||||
// don't pass this in via the environment b/c it crashes on macOS (at least, in Designed for iPad mode) since the environment doesn't get propagated through the modal popup window or something
|
||||
|
@ -49,8 +64,10 @@ struct DraftsView: View {
|
|||
.map { visibleDrafts[$0] }
|
||||
.forEach { draftsManager.remove($0) }
|
||||
}
|
||||
.appGroupedListRowBackground()
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.appGroupedListBackground(container: DraftsRepresentable.UIViewControllerType.self)
|
||||
.navigationTitle(Text("Drafts"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
|
|
|
@ -9,39 +9,20 @@
|
|||
import SwiftUI
|
||||
import Pachyderm
|
||||
|
||||
struct MainComposeTextView: View {
|
||||
struct MainComposeTextView: View, PlaceholderViewProvider {
|
||||
@ObservedObject var draft: Draft
|
||||
@State private var placeholder: Text = {
|
||||
let components = Calendar.current.dateComponents([.month, .day], from: Date())
|
||||
if components.month == 3 && components.day == 14 {
|
||||
if Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
|
||||
return Text("Happy π day!")
|
||||
}
|
||||
} else if components.month == 9 && components.day == 5 {
|
||||
// https://weirder.earth/@noracodes/109276419847254552
|
||||
// https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990
|
||||
return Text("This program posts news to thousands of machines throughout the entire populated world. Please be sure you know what you are doing.").italic()
|
||||
} else if components.month == 9 && components.day == 21 {
|
||||
return Text("Do you remember?")
|
||||
} else if components.month == 10 && components.day == 31 {
|
||||
if .random() {
|
||||
return Text("Post something spooky!")
|
||||
} else {
|
||||
return Text("Any questions?")
|
||||
}
|
||||
}
|
||||
return Text("What's on your mind?")
|
||||
}()
|
||||
@State private var placeholder: PlaceholderView = Self.placeholderView()
|
||||
|
||||
let minHeight: CGFloat = 150
|
||||
@State private var height: CGFloat?
|
||||
@Binding var becomeFirstResponder: Bool
|
||||
@State private var hasFirstAppeared = false
|
||||
@ScaledMetric private var fontSize = 20
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
Color(UIColor.secondarySystemBackground)
|
||||
colorScheme == .dark ? Color.appFill : Color(uiColor: .secondarySystemBackground)
|
||||
|
||||
if draft.text.isEmpty {
|
||||
placeholder
|
||||
|
@ -67,6 +48,38 @@ struct MainComposeTextView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
static func placeholderView() -> some View {
|
||||
let components = Calendar.current.dateComponents([.month, .day], from: Date())
|
||||
if components.month == 3 && components.day == 14,
|
||||
Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
|
||||
Text("Happy π day!")
|
||||
} else if components.month == 4 && components.day == 1 {
|
||||
Text("April Fool's!").rotationEffect(.radians(.pi), anchor: .center)
|
||||
} else if components.month == 9 && components.day == 5 {
|
||||
// https://weirder.earth/@noracodes/109276419847254552
|
||||
// https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990
|
||||
Text("This program posts news to thousands of machines throughout the entire populated world. Please be sure you know what you are doing.").italic()
|
||||
} else if components.month == 9 && components.day == 21 {
|
||||
Text("Do you remember?")
|
||||
} else if components.month == 10 && components.day == 31 {
|
||||
if .random() {
|
||||
Text("Post something spooky!")
|
||||
} else {
|
||||
Text("Any questions?")
|
||||
}
|
||||
} else {
|
||||
Text("What's on your mind?")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// exists to provide access to the type alias since the @State property needs it to be explicit
|
||||
private protocol PlaceholderViewProvider {
|
||||
associatedtype PlaceholderView: View
|
||||
@ViewBuilder
|
||||
static func placeholderView() -> PlaceholderView
|
||||
}
|
||||
|
||||
struct MainComposeWrappedTextView: UIViewRepresentable {
|
||||
|
@ -98,6 +111,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
|||
if context.coordinator.skipSettingTextOnNextUpdate {
|
||||
context.coordinator.skipSettingTextOnNextUpdate = false
|
||||
} else {
|
||||
context.coordinator.skipNextAutocompleteUpdate = true
|
||||
uiView.text = text
|
||||
}
|
||||
|
||||
|
@ -185,6 +199,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
|||
var caretScrollPositionAnimator: UIViewPropertyAnimator?
|
||||
|
||||
var skipSettingTextOnNextUpdate = false
|
||||
var skipNextAutocompleteUpdate = false
|
||||
|
||||
var toolbarElements: [ComposeUIState.ToolbarElement] {
|
||||
[.emojiPicker, .formattingButtons]
|
||||
|
@ -324,6 +339,10 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
|||
}
|
||||
|
||||
private func updateAutocompleteState() {
|
||||
guard !skipNextAutocompleteUpdate else {
|
||||
skipNextAutocompleteUpdate = false
|
||||
return
|
||||
}
|
||||
guard let textView = textView,
|
||||
let text = textView.text,
|
||||
let (lastWordStartIndex, foundFirstAtSign) = findAutocompleteLastWord() else {
|
||||
|
|
|
@ -9,18 +9,9 @@
|
|||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
class ConversationNode {
|
||||
let status: StatusMO
|
||||
var children: [ConversationNode]
|
||||
|
||||
init(status: StatusMO) {
|
||||
self.status = status
|
||||
self.children = []
|
||||
}
|
||||
}
|
||||
|
||||
class ConversationCollectionViewController: UIViewController, CollectionViewController {
|
||||
class ConversationCollectionViewController: UIViewController, CollectionViewController, RefreshableViewController {
|
||||
|
||||
private unowned let conversationViewController: ConversationViewController
|
||||
private let mastodonController: MastodonController
|
||||
private let mainStatusID: String
|
||||
private let mainStatusState: CollapseState
|
||||
|
@ -32,11 +23,12 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
|||
}
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
|
||||
init(for mainStatusID: String, state: CollapseState, mastodonController: MastodonController) {
|
||||
init(for mainStatusID: String, state: CollapseState, conversationViewController: ConversationViewController) {
|
||||
self.mainStatusID = mainStatusID
|
||||
self.mainStatusState = state
|
||||
self.statusIDToScrollToOnLoad = mainStatusID
|
||||
self.mastodonController = mastodonController
|
||||
self.conversationViewController = conversationViewController
|
||||
self.mastodonController = conversationViewController.mastodonController
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
@ -47,7 +39,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
|||
|
||||
override func loadView() {
|
||||
var config = UICollectionLayoutListConfiguration(appearance: .plain)
|
||||
config.backgroundColor = .secondarySystemBackground
|
||||
config.backgroundColor = .appSecondaryBackground
|
||||
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
|
||||
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
|
||||
}
|
||||
|
@ -55,17 +47,21 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
|||
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
|
||||
}
|
||||
config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in
|
||||
let rowsInSection = self.collectionView.numberOfItems(inSection: indexPath.section)
|
||||
let lastInSection = indexPath.row == rowsInSection - 1
|
||||
var config = sectionConfig
|
||||
config.topSeparatorVisibility = .hidden
|
||||
config.bottomSeparatorVisibility = lastInSection ? .visible : .hidden
|
||||
if case .ancestors = self.dataSource.sectionIdentifier(for: indexPath.section) {
|
||||
config.bottomSeparatorVisibility = .hidden
|
||||
} else if indexPath.row == self.collectionView.numberOfItems(inSection: indexPath.section) - 1 {
|
||||
config.bottomSeparatorVisibility = .visible
|
||||
} else {
|
||||
config.bottomSeparatorVisibility = .hidden
|
||||
}
|
||||
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||
return config
|
||||
}
|
||||
// we're not using contenetInsetsReference = .readableContent here because it always insets the cells even if
|
||||
// the collection view's actual width is narrow enough to fit in the readable width, resulting in a bit of the
|
||||
// background color always peaking through the edges
|
||||
// background color always peeking through the edges
|
||||
let layout = UICollectionViewCompositionalLayout.list(using: config)
|
||||
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
// something about the autoresizing mask breaks resizing the vc
|
||||
|
@ -74,6 +70,11 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
|||
collectionView.dragDelegate = self
|
||||
collectionView.allowsFocus = true
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
collectionView.refreshControl = UIRefreshControl()
|
||||
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
|
||||
#endif
|
||||
|
||||
dataSource = createDataSource()
|
||||
}
|
||||
|
||||
|
@ -99,7 +100,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
|||
}
|
||||
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
|
||||
switch itemIdentifier {
|
||||
case let .status(id: id, state: state, prevLink: prevLink, nextLink: nextLink):
|
||||
case let .status(id: id, node: _, state: state, prevLink: prevLink, nextLink: nextLink):
|
||||
if id == self.mainStatusID {
|
||||
return collectionView.dequeueConfiguredReusableCell(using: mainStatusCell, for: indexPath, item: (id, state, prevLink))
|
||||
} else {
|
||||
|
@ -123,45 +124,33 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
|||
loadViewIfNeeded()
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.statuses])
|
||||
snapshot.appendSections([.ancestors, .mainStatus])
|
||||
|
||||
if status.inReplyToID != nil {
|
||||
snapshot.appendItems([.loadingIndicator], toSection: .statuses)
|
||||
snapshot.appendItems([.loadingIndicator], toSection: .ancestors)
|
||||
}
|
||||
|
||||
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState, prevLink: status.inReplyToID != nil, nextLink: false)
|
||||
snapshot.appendItems([mainStatusItem], toSection: .statuses)
|
||||
// this will be replace with the actual node in the tree once it's loaded
|
||||
let tempMainNode = ConversationNode(status: status)
|
||||
let mainStatusItem = Item.status(id: mainStatusID, node: tempMainNode, state: mainStatusState, prevLink: status.inReplyToID != nil, nextLink: false)
|
||||
snapshot.appendItems([mainStatusItem], toSection: .mainStatus)
|
||||
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
|
||||
func addContext(_ context: ConversationContext, for mainStatus: StatusMO) async {
|
||||
let parentIDs = getDirectParents(inReplyTo: mainStatus.inReplyToID, from: context.ancestors)
|
||||
let parentStatuses = context.ancestors.filter { parentIDs.contains($0.id) }
|
||||
|
||||
await mastodonController.persistentContainer.addAll(statuses: parentStatuses + context.descendants)
|
||||
|
||||
var snapshot = dataSource.snapshot()
|
||||
snapshot.deleteItems([.loadingIndicator])
|
||||
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState, prevLink: mainStatus.inReplyToID != nil, nextLink: false)
|
||||
let parentItems = parentIDs.enumerated().map { index, id in
|
||||
Item.status(id: id, state: .unknown, prevLink: index > 0, nextLink: true)
|
||||
func addTree(_ tree: ConversationTree, mainStatus: StatusMO) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.ancestors, .mainStatus])
|
||||
let mainStatusItem = Item.status(id: mainStatusID, node: tree.mainStatus, state: mainStatusState, prevLink: mainStatus.inReplyToID != nil, nextLink: false)
|
||||
snapshot.appendItems([mainStatusItem], toSection: .mainStatus)
|
||||
let parentItems = tree.ancestors.enumerated().map { index, node in
|
||||
Item.status(id: node.status.id, node: node, state: .unknown, prevLink: index > 0, nextLink: true)
|
||||
}
|
||||
snapshot.insertItems(parentItems, beforeItem: mainStatusItem)
|
||||
snapshot.appendItems(parentItems, toSection: .ancestors)
|
||||
snapshot.reloadItems([mainStatusItem])
|
||||
|
||||
// fetch all descendant status managed objects
|
||||
let descendantIDs = context.descendants.map(\.id)
|
||||
let request = StatusMO.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id IN %@", descendantIDs)
|
||||
|
||||
if let descendants = try? mastodonController.persistentContainer.viewContext.fetch(request) {
|
||||
// convert array of descendant statuses into tree of sub-threads
|
||||
let childThreads = self.getDescendantThreads(mainStatus: mainStatus, descendants: descendants)
|
||||
|
||||
// convert sub-threads into items for section and add to snapshot
|
||||
self.addFlattenedChildThreadsToSnapshot(childThreads, mainStatus: mainStatus, snapshot: &snapshot)
|
||||
}
|
||||
// convert sub-threads into items for section and add to snapshot
|
||||
self.addFlattenedChildThreadsToSnapshot(tree.descendants, mainStatus: mainStatus, snapshot: &snapshot)
|
||||
|
||||
self.dataSource.apply(snapshot, animatingDifferences: false) {
|
||||
let item: Item
|
||||
|
@ -171,7 +160,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
|||
position = .centeredVertically
|
||||
} else {
|
||||
item = snapshot.itemIdentifiers.first {
|
||||
if case .status(id: self.statusIDToScrollToOnLoad, _, _, _) = $0 {
|
||||
if case .status(id: self.statusIDToScrollToOnLoad, _, _, _, _) = $0 {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
|
@ -187,54 +176,6 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
|||
}
|
||||
}
|
||||
|
||||
private func getDirectParents(inReplyTo inReplyToID: String?, from statuses: [Status]) -> [String] {
|
||||
var statuses = statuses
|
||||
var parents = [String]()
|
||||
|
||||
var parentID: String? = inReplyToID
|
||||
|
||||
while parentID != nil, let parentIndex = statuses.firstIndex(where: { $0.id == parentID }) {
|
||||
let parentStatus = statuses.remove(at: parentIndex)
|
||||
parents.insert(parentStatus.id, at: 0)
|
||||
parentID = parentStatus.inReplyToID
|
||||
}
|
||||
|
||||
return parents
|
||||
}
|
||||
|
||||
private func getDescendantThreads(mainStatus: StatusMO, descendants: [StatusMO]) -> [ConversationNode] {
|
||||
var descendants = descendants
|
||||
|
||||
func removeAllInReplyTo(id: String) -> [StatusMO] {
|
||||
let statuses = descendants.filter { $0.inReplyToID == id }
|
||||
descendants.removeAll { $0.inReplyToID == id }
|
||||
return statuses
|
||||
}
|
||||
|
||||
var nodes: [String: ConversationNode] = [
|
||||
mainStatus.id: ConversationNode(status: mainStatus)
|
||||
]
|
||||
|
||||
var idsToCheck = [mainStatusID]
|
||||
|
||||
while !idsToCheck.isEmpty {
|
||||
let inReplyToID = idsToCheck.removeFirst()
|
||||
let nodeForID = nodes[inReplyToID]!
|
||||
|
||||
let inReply = removeAllInReplyTo(id: inReplyToID)
|
||||
for reply in inReply {
|
||||
idsToCheck.append(reply.id)
|
||||
|
||||
let replyNode = ConversationNode(status: reply)
|
||||
nodes[reply.id] = replyNode
|
||||
|
||||
nodeForID.children.append(replyNode)
|
||||
}
|
||||
}
|
||||
|
||||
return nodes[mainStatusID]!.children
|
||||
}
|
||||
|
||||
private func addFlattenedChildThreadsToSnapshot(_ childThreads: [ConversationNode], mainStatus: StatusMO, snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>) {
|
||||
var childThreads = childThreads
|
||||
|
||||
|
@ -248,7 +189,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
|||
for node in childThreads {
|
||||
let section = Section.childThread(firstStatusID: node.status.id)
|
||||
snapshot.appendSections([section])
|
||||
snapshot.appendItems([.status(id: node.status.id, state: .unknown, prevLink: false, nextLink: !node.children.isEmpty)], toSection: section)
|
||||
snapshot.appendItems([.status(id: node.status.id, node: node, state: .unknown, prevLink: false, nextLink: !node.children.isEmpty)], toSection: section)
|
||||
|
||||
var currentNode = node
|
||||
while true {
|
||||
|
@ -271,7 +212,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
|||
}
|
||||
|
||||
currentNode = next
|
||||
snapshot.appendItems([.status(id: next.status.id, state: .unknown, prevLink: true, nextLink: !next.children.isEmpty)], toSection: section)
|
||||
snapshot.appendItems([.status(id: next.status.id, node: next, state: .unknown, prevLink: true, nextLink: !next.children.isEmpty)], toSection: section)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -280,7 +221,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
|||
var snapshot = dataSource.snapshot()
|
||||
var cellsToMask: [StatusCollectionViewCell] = []
|
||||
for item in snapshot.itemIdentifiers {
|
||||
guard case .status(id: _, state: let state, prevLink: _, nextLink: _) = item,
|
||||
guard case .status(id: _, node: _, state: let state, prevLink: _, nextLink: _) = item,
|
||||
state.collapsible == true else {
|
||||
continue
|
||||
}
|
||||
|
@ -307,21 +248,31 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
|||
}
|
||||
}
|
||||
|
||||
@objc func refresh() {
|
||||
Task {
|
||||
await conversationViewController.refreshContext()
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
self.collectionView.refreshControl!.endRefreshing()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ConversationCollectionViewController {
|
||||
enum Section: Hashable {
|
||||
case statuses
|
||||
case ancestors
|
||||
case mainStatus
|
||||
case childThread(firstStatusID: String)
|
||||
}
|
||||
enum Item: Hashable {
|
||||
case status(id: String, state: CollapseState, prevLink: Bool, nextLink: Bool)
|
||||
case status(id: String, node: ConversationNode, state: CollapseState, prevLink: Bool, nextLink: Bool)
|
||||
case expandThread(childThreads: [ConversationNode], inline: Bool)
|
||||
case loadingIndicator
|
||||
|
||||
static func ==(lhs: Item, rhs: Item) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case let (.status(id: a, state: _, prevLink: aPrev, nextLink: aNext), .status(id: b, state: _, prevLink: bPrev, nextLink: bNext)):
|
||||
case let (.status(id: a, node: _, state: _, prevLink: aPrev, nextLink: aNext), .status(id: b, node: _, state: _, prevLink: bPrev, nextLink: bNext)):
|
||||
return a == b && aPrev == bPrev && aNext == bNext
|
||||
case let (.expandThread(childThreads: a, inline: aInline), .expandThread(childThreads: b, inline: bInline)):
|
||||
return a.count == b.count && zip(a, b).allSatisfy { $0.status.id == $1.status.id } && aInline == bInline
|
||||
|
@ -334,7 +285,7 @@ extension ConversationCollectionViewController {
|
|||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case let .status(id: id, state: _, prevLink: prevLink, nextLink: nextLink):
|
||||
case let .status(id: id, node: _, state: _, prevLink: prevLink, nextLink: nextLink):
|
||||
hasher.combine(0)
|
||||
hasher.combine(id)
|
||||
hasher.combine(prevLink)
|
||||
|
@ -355,7 +306,7 @@ extension ConversationCollectionViewController {
|
|||
extension ConversationCollectionViewController: UICollectionViewDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
|
||||
switch dataSource.itemIdentifier(for: indexPath) {
|
||||
case .status(id: let id, state: _, prevLink: _, nextLink: _):
|
||||
case .status(id: let id, node: _, state: _, prevLink: _, nextLink: _):
|
||||
return id != mainStatusID
|
||||
case .expandThread(childThreads: _, inline: _):
|
||||
return true
|
||||
|
@ -370,12 +321,25 @@ extension ConversationCollectionViewController: UICollectionViewDelegate {
|
|||
break
|
||||
case .loadingIndicator:
|
||||
break
|
||||
case .status(id: let id, state: let state, _, _):
|
||||
selected(status: id, state: state.copy())
|
||||
case .status(id: let id, node: let node, state: let state, _, _):
|
||||
// we can only take the fast path if the user tapped on a descendant status.
|
||||
// if the current main status is C, or one of its descendants, and the user taps A, then B won't be loaded:
|
||||
// A
|
||||
// / \
|
||||
// B C
|
||||
if case .childThread(_) = dataSource.sectionIdentifier(for: indexPath.section) {
|
||||
let tree = ConversationTree(ancestors: buildNewAncestors(above: indexPath), mainStatus: node)
|
||||
let conv = ConversationViewController(preloadedTree: tree, state: state.copy(), mastodonController: mastodonController)
|
||||
conv.showStatusesAutomatically = showStatusesAutomatically
|
||||
show(conv)
|
||||
} else {
|
||||
selected(status: id, state: state.copy())
|
||||
}
|
||||
case .expandThread(childThreads: let childThreads, inline: _):
|
||||
if case .status(id: let id, state: let state, _, _) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) {
|
||||
// todo: it would be nice to avoid re-fetching the context here, since we should have all the necessary information already
|
||||
let conv = ConversationViewController(for: id, state: state.copy(), mastodonController: mastodonController)
|
||||
let indexPathBeforeExpandThread = IndexPath(row: indexPath.row - 1, section: indexPath.section)
|
||||
if case .status(id: _, node: let node, state: let state, _, _) = dataSource.itemIdentifier(for: indexPathBeforeExpandThread) {
|
||||
let tree = ConversationTree(ancestors: buildNewAncestors(above: indexPathBeforeExpandThread), mainStatus: node)
|
||||
let conv = ConversationViewController(preloadedTree: tree, state: state.copy(), mastodonController: mastodonController)
|
||||
conv.statusIDToScrollToOnLoad = childThreads.first!.status.id
|
||||
conv.showStatusesAutomatically = showStatusesAutomatically
|
||||
show(conv)
|
||||
|
@ -383,6 +347,34 @@ extension ConversationCollectionViewController: UICollectionViewDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// ConversationNode doesn't know about its parent, so we reconstruct that info from the data source
|
||||
private func buildNewAncestors(above indexPath: IndexPath) -> [ConversationNode] {
|
||||
let snapshot = dataSource.snapshot()
|
||||
let currentAncestors = snapshot.itemIdentifiers(inSection: .ancestors).compactMap {
|
||||
if case .status(id: _, node: let node, _, _, _) = $0 {
|
||||
return node
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
let currentMainStatus = snapshot.itemIdentifiers(inSection: .mainStatus).compactMap {
|
||||
if case .status(id: _, node: let node, _, _, _) = $0 {
|
||||
return node
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
let parentsInCurrentSection = snapshot.itemIdentifiers(inSection: dataSource.sectionIdentifier(for: indexPath.section)!)[0..<indexPath.row].compactMap {
|
||||
if case .status(id: _, node: let node, _, _, _) = $0 {
|
||||
return node
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return currentAncestors + currentMainStatus + parentsInCurrentSection
|
||||
}
|
||||
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
return (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration()
|
||||
}
|
||||
|
|
|
@ -0,0 +1,101 @@
|
|||
//
|
||||
// ConversationTree.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 2/4/23.
|
||||
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Pachyderm
|
||||
|
||||
class ConversationNode {
|
||||
let status: StatusMO
|
||||
var children: [ConversationNode]
|
||||
|
||||
init(status: StatusMO) {
|
||||
self.status = status
|
||||
self.children = []
|
||||
}
|
||||
}
|
||||
struct ConversationTree {
|
||||
let ancestors: [ConversationNode]
|
||||
let mainStatus: ConversationNode
|
||||
var descendants: [ConversationNode] {
|
||||
mainStatus.children
|
||||
}
|
||||
|
||||
init(ancestors: [ConversationNode], mainStatus: ConversationNode) {
|
||||
self.ancestors = ancestors
|
||||
self.mainStatus = mainStatus
|
||||
}
|
||||
|
||||
static func build(for mainStatus: StatusMO, ancestors: [StatusMO], descendants: [StatusMO]) -> ConversationTree {
|
||||
let mainStatusNode = ConversationNode(status: mainStatus)
|
||||
let ancestors = buildAncestorNodes(mainStatusNode: mainStatusNode, ancestors: ancestors)
|
||||
buildDescendantNodes(mainStatusNode: mainStatusNode, descendants: descendants)
|
||||
return ConversationTree(ancestors: ancestors, mainStatus: mainStatusNode)
|
||||
}
|
||||
|
||||
private static func buildAncestorNodes(mainStatusNode: ConversationNode, ancestors: [StatusMO]) -> [ConversationNode] {
|
||||
var statuses = ancestors
|
||||
var parents = [ConversationNode]()
|
||||
|
||||
var parentID: String? = mainStatusNode.status.inReplyToID
|
||||
|
||||
while let currentParentID = parentID,
|
||||
let parentIndex = statuses.firstIndex(where: { $0.id == currentParentID }) {
|
||||
let parentStatus = statuses.remove(at: parentIndex)
|
||||
|
||||
let node = ConversationNode(status: parentStatus)
|
||||
parents.insert(node, at: 0)
|
||||
|
||||
parentID = parentStatus.inReplyToID
|
||||
}
|
||||
|
||||
// once the parents list is built and in-order, then we walk through and set each node's children
|
||||
for (index, node) in parents.enumerated() {
|
||||
if index == parents.count - 1 {
|
||||
// the last parent is the direct parent of the main status
|
||||
node.children = [mainStatusNode]
|
||||
} else {
|
||||
// otherwise, it's the parent of the status that comes immediately after it in the parents list
|
||||
node.children = [parents[index + 1]]
|
||||
}
|
||||
}
|
||||
|
||||
return parents
|
||||
}
|
||||
|
||||
// doesn't return anything, since we're modifying the main status node in-place
|
||||
private static func buildDescendantNodes(mainStatusNode: ConversationNode, descendants: [StatusMO]) {
|
||||
var descendants = descendants
|
||||
|
||||
func removeAllInReplyTo(id: String) -> [StatusMO] {
|
||||
let statuses = descendants.filter { $0.inReplyToID == id }
|
||||
descendants.removeAll { $0.inReplyToID == id }
|
||||
return statuses
|
||||
}
|
||||
|
||||
var nodes: [String: ConversationNode] = [
|
||||
mainStatusNode.status.id: mainStatusNode
|
||||
]
|
||||
|
||||
var idsToCheck = [mainStatusNode.status.id]
|
||||
|
||||
while !idsToCheck.isEmpty {
|
||||
let inReplyToID = idsToCheck.removeFirst()
|
||||
let nodeForID = nodes[inReplyToID]!
|
||||
|
||||
let inReply = removeAllInReplyTo(id: inReplyToID)
|
||||
for reply in inReply {
|
||||
idsToCheck.append(reply.id)
|
||||
|
||||
let replyNode = ConversationNode(status: reply)
|
||||
nodes[reply.id] = replyNode
|
||||
|
||||
nodeForID.children.append(replyNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -77,6 +77,14 @@ class ConversationViewController: UIViewController {
|
|||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
init(preloadedTree: ConversationTree, state mainStatusState: CollapseState, mastodonController: MastodonController) {
|
||||
self.mode = .preloaded(preloadedTree)
|
||||
self.mainStatusState = mainStatusState
|
||||
self.mastodonController = mastodonController
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
@ -86,7 +94,7 @@ class ConversationViewController: UIViewController {
|
|||
|
||||
title = NSLocalizedString("Conversation", comment: "conversation screen title")
|
||||
|
||||
view.backgroundColor = .secondarySystemBackground
|
||||
view.backgroundColor = .appSecondaryBackground
|
||||
|
||||
collapseBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "eye.fill")!, style: .plain, target: self, action: #selector(toggleCollapseButtonPressed))
|
||||
updateVisibilityBarButtonItem()
|
||||
|
@ -115,9 +123,15 @@ class ConversationViewController: UIViewController {
|
|||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
Task {
|
||||
if case .unloaded = state {
|
||||
await loadMainStatus()
|
||||
if case .unloaded = state {
|
||||
if case .preloaded(let tree) = mode {
|
||||
// when everything is preloaded, we're on the fast path and want to avoid any async work
|
||||
// just kicking off a MainActor task causes a delay before the content appears, even if the task doesn't suspend
|
||||
mainStatusLoaded(tree.mainStatus.status)
|
||||
} else {
|
||||
Task { @MainActor in
|
||||
await loadMainStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -142,9 +156,20 @@ class ConversationViewController: UIViewController {
|
|||
|
||||
// MARK: Loading
|
||||
|
||||
@MainActor
|
||||
private func loadMainStatus() async {
|
||||
guard let mainStatusID = await resolveStatusIfNecessary() else {
|
||||
return
|
||||
let mainStatusID: String
|
||||
switch mode {
|
||||
case .localID(let id):
|
||||
mainStatusID = id
|
||||
case .resolve(let url):
|
||||
if let id = await resolveStatus(url: url) {
|
||||
mainStatusID = id
|
||||
} else {
|
||||
return
|
||||
}
|
||||
case .preloaded(_):
|
||||
fatalError("unreachable")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
@ -166,7 +191,7 @@ class ConversationViewController: UIViewController {
|
|||
Task {
|
||||
await doLoadMainStatus()
|
||||
}
|
||||
await mainStatusLoaded(cached)
|
||||
mainStatusLoaded(cached)
|
||||
} else {
|
||||
// otherwise, show a loading indicator while loading the main status
|
||||
let indicator = UIActivityIndicatorView(style: .medium)
|
||||
|
@ -174,77 +199,110 @@ class ConversationViewController: UIViewController {
|
|||
state = .loading(indicator)
|
||||
|
||||
if let status = await doLoadMainStatus() {
|
||||
await mainStatusLoaded(status)
|
||||
mainStatusLoaded(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func resolveStatusIfNecessary() async -> String? {
|
||||
switch mode {
|
||||
case .localID(let id):
|
||||
return id
|
||||
case .resolve(let url):
|
||||
let indicator = UIActivityIndicatorView(style: .medium)
|
||||
indicator.startAnimating()
|
||||
state = .loading(indicator)
|
||||
private func resolveStatus(url: URL) async -> String? {
|
||||
let indicator = UIActivityIndicatorView(style: .medium)
|
||||
indicator.startAnimating()
|
||||
state = .loading(indicator)
|
||||
|
||||
let url = WebURL(url)!
|
||||
let request = Client.search(query: url.serialized(), types: [.statuses], resolve: true)
|
||||
do {
|
||||
let (results, _) = try await mastodonController.run(request)
|
||||
guard let status = results.statuses.first(where: { $0.url == url }) else {
|
||||
throw UnableToResolveError()
|
||||
}
|
||||
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
|
||||
mode = .localID(status.id)
|
||||
return status.id
|
||||
} catch {
|
||||
state = .unableToResolve(error)
|
||||
return nil
|
||||
let url = WebURL(url)!.serialized(excludingFragment: true)
|
||||
let request = Client.search(query: url, types: [.statuses], resolve: true)
|
||||
do {
|
||||
let (results, _) = try await mastodonController.run(request)
|
||||
guard let status = results.statuses.first(where: { $0.url?.serialized() == url }) else {
|
||||
throw UnableToResolveError()
|
||||
}
|
||||
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
|
||||
mode = .localID(status.id)
|
||||
return status.id
|
||||
} catch {
|
||||
state = .unableToResolve(error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func mainStatusLoaded(_ mainStatus: StatusMO) async {
|
||||
let vc = ConversationCollectionViewController(for: mainStatus.id, state: mainStatusState, mastodonController: mastodonController)
|
||||
private func mainStatusLoaded(_ mainStatus: StatusMO) {
|
||||
if let accountID = mastodonController.accountInfo?.id {
|
||||
userActivity = UserActivityManager.showConversationActivity(mainStatusID: mainStatus.id, accountID: accountID)
|
||||
}
|
||||
|
||||
let vc = ConversationCollectionViewController(for: mainStatus.id, state: mainStatusState, conversationViewController: self)
|
||||
vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad ?? mainStatus.id
|
||||
vc.showStatusesAutomatically = showStatusesAutomatically
|
||||
vc.addMainStatus(mainStatus)
|
||||
state = .displaying(vc)
|
||||
|
||||
await loadContext(for: mainStatus)
|
||||
if case .preloaded(let tree) = mode {
|
||||
vc.addTree(tree, mainStatus: mainStatus)
|
||||
} else {
|
||||
Task { @MainActor in
|
||||
await loadTree(for: mainStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadContext(for mainStatus: StatusMO) async {
|
||||
guard case .displaying(_) = state else {
|
||||
private func loadTree(for mainStatus: StatusMO) async {
|
||||
guard case .displaying(_) = state,
|
||||
let context = await loadContext(for: mainStatus) else {
|
||||
return
|
||||
}
|
||||
|
||||
await mastodonController.persistentContainer.addAll(statuses: context.ancestors + context.descendants)
|
||||
|
||||
let ancestorIDs = context.ancestors.map(\.id)
|
||||
let ancestorsReq = StatusMO.fetchRequest()
|
||||
ancestorsReq.predicate = NSPredicate(format: "id in %@", ancestorIDs)
|
||||
let ancestors = try? mastodonController.persistentContainer.viewContext.fetch(ancestorsReq)
|
||||
|
||||
let descendantIDs = context.descendants.map(\.id)
|
||||
let descendantsReq = StatusMO.fetchRequest()
|
||||
descendantsReq.predicate = NSPredicate(format: "id IN %@", descendantIDs)
|
||||
let descendants = try? mastodonController.persistentContainer.viewContext.fetch(descendantsReq)
|
||||
|
||||
let tree = ConversationTree.build(for: mainStatus, ancestors: ancestors ?? [], descendants: descendants ?? [])
|
||||
|
||||
guard case .displaying(let vc) = state else {
|
||||
return
|
||||
}
|
||||
vc.addTree(tree, mainStatus: mainStatus)
|
||||
}
|
||||
|
||||
private func loadContext(for mainStatus: StatusMO) async -> ConversationContext? {
|
||||
let request = Status.getContext(mainStatus.id)
|
||||
do {
|
||||
let (context, _) = try await mastodonController.run(request)
|
||||
guard case .displaying(let vc) = state else {
|
||||
return
|
||||
}
|
||||
|
||||
await vc.addContext(context, for: mainStatus)
|
||||
return context
|
||||
} catch {
|
||||
guard case .displaying(_) = state else {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
let error = error as! Client.Error
|
||||
|
||||
let config = ToastConfiguration(from: error, with: "Error Loading Context", in: self) { [weak self] toast in
|
||||
toast.dismissToast(animated: true)
|
||||
await self?.loadContext(for: mainStatus)
|
||||
await self?.loadTree(for: mainStatus)
|
||||
}
|
||||
self.showToast(configuration: config, animated: true)
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func refreshContext() async {
|
||||
guard case .localID(let id) = mode,
|
||||
let status = mastodonController.persistentContainer.status(for: id),
|
||||
case .displaying(_) = state else {
|
||||
return
|
||||
}
|
||||
await loadTree(for: status)
|
||||
}
|
||||
|
||||
private func showMainStatusNotFound() {
|
||||
let notFoundView = StatusNotFoundView(frame: .zero)
|
||||
notFoundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
@ -341,6 +399,7 @@ extension ConversationViewController {
|
|||
enum Mode {
|
||||
case localID(String)
|
||||
case resolve(URL)
|
||||
case preloaded(ConversationTree)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -366,6 +425,17 @@ extension ConversationViewController: TuskerNavigationDelegate {
|
|||
var apiController: MastodonController! { mastodonController }
|
||||
}
|
||||
|
||||
extension ConversationViewController: StateRestorableViewController {
|
||||
func stateRestorationActivity() -> NSUserActivity? {
|
||||
if let accountID = mastodonController.accountInfo?.id,
|
||||
case .localID(let id) = mode {
|
||||
return UserActivityManager.showConversationActivity(mainStatusID: id, accountID: accountID)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConversationViewController: ToastableViewController {
|
||||
var toastScrollView: UIScrollView? {
|
||||
if case .displaying(let vc) = state {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue