Compare commits

..

No commits in common. "ee630cf9df26915787c354b7cbaf8dafac3e7d7b" and "8fc915d6a0534cee63881013f5d2a7d8cf567f91" have entirely different histories.

223 changed files with 2107 additions and 6470 deletions

View File

@ -1,47 +0,0 @@
## 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

View File

@ -1,97 +1,5 @@
# 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

View File

@ -1,7 +0,0 @@
# Haptic Feedback
## Selection changed
`UISelectionFeedbackGenerator`
## Actions
On success, `UIImpactFeedbackGenerator` with the `.light` style. On error, `UINotificationFeedbackGenerator` with the `.error` type.

View File

@ -62,7 +62,7 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
guard case .idle = state else {
if animated,
case .ducked(_, placeholder: let placeholder) = state {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
let origConstant = placeholder.topConstraint.constant
UIView.animateKeyframes(withDuration: 0.4, delay: 0) {
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {

View File

@ -155,27 +155,6 @@ 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
@ -199,10 +178,8 @@ public class Client {
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
}
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 getFavourites() -> Request<[Status]> {
return Request<[Status]>(method: .get, path: "/api/v1/favourites")
}
public static func getRelationships(accounts: [String]? = nil) -> Request<[Relationship]> {
@ -417,46 +394,32 @@ public class Client {
}
// MARK: - Instance
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)
public static func getTrendingHashtags(limit: Int? = nil) -> Request<[Hashtag]> {
let parameters: [Parameter]
if let limit = limit {
parameters = ["limit" => limit]
} else {
parameters = []
}
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends", queryParameters: 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)
public static func getTrendingStatuses(limit: Int? = nil) -> Request<[Status]> {
let parameters: [Parameter]
if let limit = limit {
parameters = ["limit" => limit]
} else {
parameters = []
}
return Request(method: .get, path: "/api/v1/trends/statuses", queryParameters: 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)
public static func getTrendingLinks(limit: Int? = nil) -> Request<[Card]> {
let parameters: [Parameter]
if let limit = limit {
parameters = ["limit" => limit]
} else {
parameters = []
}
return Request(method: .get, path: "/api/v1/trends/links", queryParameters: parameters)
}
@ -484,7 +447,7 @@ public class Client {
}
extension Client {
public struct Error: LocalizedError, Sendable {
public struct Error: LocalizedError {
public let requestMethod: Method
public let requestEndpoint: Endpoint
public let type: ErrorType
@ -519,7 +482,7 @@ extension Client {
}
}
}
public enum ErrorType: LocalizedError, Sendable {
public enum ErrorType: LocalizedError {
case networkError(Swift.Error)
case unexpectedStatus(Int)
case invalidRequest

View File

@ -8,7 +8,7 @@
import Foundation
public final class Account: AccountProtocol, Decodable, Sendable {
public final class Account: AccountProtocol, Decodable {
public let id: String
public let username: String
public let acct: String
@ -25,7 +25,7 @@ public final class Account: AccountProtocol, Decodable, Sendable {
public let avatarStatic: URL?
public let header: URL?
public let headerStatic: URL?
public let emojis: [Emoji]
public private(set) var emojis: [Emoji]
public let moved: Bool?
public let movedTo: Account?
public let fields: [Field]
@ -109,12 +109,6 @@ public final class Account: AccountProtocol, Decodable, Sendable {
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")
}
@ -171,7 +165,7 @@ extension Account: CustomDebugStringConvertible {
}
extension Account {
public struct Field: Codable, Equatable, Sendable {
public struct Field: Codable {
public let name: String
public let value: String
public let verifiedAt: Date?

View File

@ -8,11 +8,11 @@
import Foundation
public struct Application: Decodable, Sendable {
public class Application: Decodable {
public let name: String
public let website: URL?
public init(from decoder: Decoder) throws {
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.name = try container.decode(String.self, forKey: .name)

View File

@ -8,7 +8,7 @@
import Foundation
public struct Attachment: Codable, Sendable {
public class Attachment: Codable {
public let id: String
public let kind: Kind
public let url: URL
@ -25,7 +25,7 @@ public struct Attachment: Codable, Sendable {
], nil))
}
public init(from decoder: Decoder) throws {
required 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 struct Attachment: Codable, Sendable {
}
extension Attachment {
public enum Kind: String, Codable, Sendable {
public enum Kind: String, Codable {
case image
case video
case gifv
@ -77,7 +77,7 @@ extension Attachment {
}
extension Attachment {
public struct Metadata: Codable, Sendable {
public struct Metadata: Codable {
public let length: String?
public let duration: Float?
public let audioEncoding: String?
@ -108,7 +108,7 @@ extension Attachment {
}
}
public struct ImageMetadata: Codable, Sendable {
public struct ImageMetadata: Codable {
public let width: Int?
public let height: Int?
public let size: String?

View File

@ -9,7 +9,7 @@
import Foundation
import WebURL
public struct Card: Codable, Sendable {
public class Card: Codable {
public let url: WebURL
public let title: String
public let description: String
@ -26,7 +26,7 @@ public struct Card: Codable, Sendable {
/// Only present when returned from the trending links endpoint
public let history: [History]?
public init(from decoder: Decoder) throws {
public required 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 struct Card: Codable, Sendable {
}
extension Card {
public enum Kind: String, Codable, Sendable {
public enum Kind: String, Codable {
case link
case photo
case video

View File

@ -8,7 +8,7 @@
import Foundation
public struct ConversationContext: Decodable, Sendable {
public class ConversationContext: Decodable {
public let ancestors: [Status]
public let descendants: [Status]

View File

@ -8,7 +8,7 @@
import Foundation
public enum DirectoryOrder: String, CaseIterable, Sendable {
public enum DirectoryOrder: String, CaseIterable {
case active
case new
}

View File

@ -9,7 +9,7 @@
import Foundation
import WebURL
public struct Emoji: Codable, Sendable {
public class Emoji: Codable {
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 struct Emoji: Codable, Sendable {
public let visibleInPicker: Bool
public let category: String?
public init(from decoder: Decoder) throws {
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.shortcode = try container.decode(String.self, forKey: .shortcode)

View File

@ -8,7 +8,7 @@
import Foundation
public struct FilterV1: Decodable, Sendable {
public struct FilterV1: Decodable {
public let id: String
public let phrase: String
private let context: [String]
@ -45,7 +45,7 @@ public struct FilterV1: Decodable, Sendable {
}
extension FilterV1 {
public enum Context: String, Decodable, CaseIterable, Sendable {
public enum Context: String, Decodable, CaseIterable {
case home
case notifications
case `public`

View File

@ -7,7 +7,7 @@
import Foundation
public struct FilterV2: Decodable, Sendable {
public struct FilterV2: Decodable {
public let id: String
public let title: String
public let context: [FilterV1.Context]
@ -80,14 +80,14 @@ public struct FilterV2: Decodable, Sendable {
}
extension FilterV2 {
public enum Action: String, Decodable, Hashable, CaseIterable, Sendable {
public enum Action: String, Decodable, Hashable, CaseIterable {
case warn
case hide
}
}
extension FilterV2 {
public struct Keyword: Decodable, Sendable {
public struct Keyword: Decodable {
public let id: String
public let keyword: String
public let wholeWord: Bool

View File

@ -10,7 +10,7 @@ import Foundation
import WebURL
import WebURLFoundationExtras
public struct Hashtag: Codable, Sendable {
public class Hashtag: Codable {
public let name: String
public let url: WebURL
/// Only present when returned from the trending hashtags endpoint
@ -25,7 +25,7 @@ public struct Hashtag: Codable, Sendable {
self.following = nil
}
public init(from decoder: Decoder) throws {
public required 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

View File

@ -8,12 +8,12 @@
import Foundation
public struct History: Codable, Sendable {
public class History: Codable {
public let day: Date
public let uses: Int
public let accounts: Int
public init(from decoder: Decoder) throws {
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let day = try? container.decode(Date.self, forKey: .day) {

View File

@ -8,7 +8,7 @@
import Foundation
public struct Instance: Decodable, Sendable {
public class Instance: Decodable {
public let uri: String
public let title: String
public let description: String
@ -37,7 +37,7 @@ public struct Instance: Decodable, Sendable {
}
// we need a custom decoder, because all API-compatible implementations don't return some data in the same format
public init(from decoder: Decoder) throws {
public required 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 struct Instance: Decodable, Sendable {
}
extension Instance {
public struct Stats: Decodable, Sendable {
public struct Stats: Decodable {
public let domainCount: Int?
public let statusCount: Int?
public let userCount: Int?
@ -107,7 +107,7 @@ extension Instance {
}
extension Instance {
public struct Configuration: Decodable, Sendable {
public struct Configuration: Decodable {
public let statuses: StatusesConfiguration
public let mediaAttachments: MediaAttachmentsConfiguration
/// Use Instance.pollsConfiguration to support older instance that don't have this nested
@ -122,7 +122,7 @@ extension Instance {
}
extension Instance {
public struct StatusesConfiguration: Decodable, Sendable {
public struct StatusesConfiguration: Decodable {
public let maxCharacters: Int
public let maxMediaAttachments: Int
public let charactersReservedPerURL: Int
@ -136,7 +136,7 @@ extension Instance {
}
extension Instance {
public struct MediaAttachmentsConfiguration: Decodable, Sendable {
public struct MediaAttachmentsConfiguration: Decodable {
public let supportedMIMETypes: [String]
public let imageSizeLimit: Int
public let imageMatrixLimit: Int
@ -156,7 +156,7 @@ extension Instance {
}
extension Instance {
public struct PollsConfiguration: Decodable, Sendable {
public struct PollsConfiguration: Decodable {
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, Sendable {
public struct Rule: Decodable, Identifiable {
public let id: String
public let text: String
}

View File

@ -8,7 +8,7 @@
import Foundation
public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable {
public class List: Decodable, Equatable, Hashable {
public let id: String
public let title: String
@ -16,11 +16,6 @@ public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable {
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
}
@ -30,28 +25,28 @@ public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable {
hasher.combine(title)
}
public static func getAccounts(_ listID: String, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(listID)/accounts")
public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts")
request.range = range
return request
}
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 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 delete(_ listID: String) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(listID)")
public static func delete(_ list: List) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)")
}
public static func add(_ listID: String, accounts accountIDs: [String]) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/lists/\(listID)/accounts", body: ParametersBody(
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(
"account_ids" => accountIDs
))
}
public static func remove(_ listID: String, accounts accountIDs: [String]) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(listID)/accounts", body: ParametersBody(
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(
"account_ids" => accountIDs
))
}

View File

@ -8,7 +8,7 @@
import Foundation
public struct LoginSettings: Decodable, Sendable {
public class LoginSettings: Decodable {
public let accessToken: String
private let scope: String?

View File

@ -9,7 +9,7 @@
import Foundation
import WebURL
public struct Mention: Codable, Sendable {
public struct Mention: Codable {
public let url: WebURL
public let username: String
public let acct: String

View File

@ -8,11 +8,11 @@
import Foundation
public struct NodeInfo: Decodable, Sendable {
public struct NodeInfo: Decodable {
public let version: String
public let software: Software
public struct Software: Decodable, Sendable {
public struct Software: Decodable {
public let name: String
public let version: String
}

View File

@ -8,14 +8,14 @@
import Foundation
public struct Notification: Decodable, Sendable {
public class Notification: Decodable {
public let id: String
public let kind: Kind
public let createdAt: Date
public let account: Account
public let status: Status?
public init(from decoder: Decoder) throws {
public required 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 struct Notification: Decodable, Sendable {
}
extension Notification {
public enum Kind: String, Decodable, CaseIterable, Sendable {
public enum Kind: String, Decodable, CaseIterable {
case mention
case reblog
case favourite

View File

@ -8,7 +8,7 @@
import Foundation
public struct Poll: Codable, Sendable {
public final class Poll: Codable {
public let id: String
public let expiresAt: Date?
public let expired: Bool
@ -43,7 +43,7 @@ public struct Poll: Codable, Sendable {
}
extension Poll {
public struct Option: Codable, Sendable {
public final class Option: Codable {
public let title: String
public let votesCount: Int?

View File

@ -1,13 +0,0 @@
//
// ListProtocol.swift
// Pachyderm
//
// Created by Shadowfacts on 2/25/23.
//
import Foundation
public protocol ListProtocol {
var id: String { get }
var title: String { get }
}

View File

@ -8,7 +8,7 @@
import Foundation
public struct PushSubscription: Decodable, Sendable {
public class PushSubscription: Decodable {
public let id: String
public let endpoint: URL
public let serverKey: String

View File

@ -8,12 +8,12 @@
import Foundation
public struct RegisteredApplication: Decodable, Sendable {
public class RegisteredApplication: Decodable {
public let id: String
public let clientID: String
public let clientSecret: String
public init(from decoder: Decoder) throws {
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Pixelfed API returns id/client_id as numbers instead of strings

View File

@ -8,7 +8,7 @@
import Foundation
public struct Relationship: Decodable, Sendable {
public class Relationship: Decodable {
public let id: String
public let following: Bool
public let followedBy: Bool

View File

@ -8,7 +8,7 @@
import Foundation
public struct Report: Decodable, Sendable {
public class Report: Decodable {
public let id: String
public let actionTaken: Bool

View File

@ -8,7 +8,7 @@
import Foundation
public enum Scope: String, Sendable {
public enum Scope: String {
case read
case write
case follow

View File

@ -8,7 +8,7 @@
import Foundation
public enum SearchResultType: String, Sendable {
public enum SearchResultType: String {
case accounts
case hashtags
case statuses

View File

@ -8,7 +8,7 @@
import Foundation
public struct SearchResults: Decodable, Sendable {
public class SearchResults: Decodable {
public let accounts: [Account]
public let statuses: [Status]
public let hashtags: [Hashtag]

View File

@ -9,7 +9,7 @@
import Foundation
import WebURL
public final class Status: StatusProtocol, Decodable, Sendable {
public final class Status: StatusProtocol, Decodable {
public let id: String
public let uri: String
public let url: WebURL?
@ -44,47 +44,6 @@ public final class Status: StatusProtocol, Decodable, Sendable {
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")
}
@ -188,7 +147,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
}
extension Status {
public enum Visibility: String, Codable, CaseIterable, Sendable {
public enum Visibility: String, Codable, CaseIterable {
case `public`
case unlisted
case `private`

View File

@ -8,7 +8,7 @@
import Foundation
public enum StatusContentType: String, Codable, CaseIterable, Sendable {
public enum StatusContentType: String, Codable, CaseIterable {
case plain, markdown, html
var mimeType: String {

View File

@ -7,7 +7,7 @@
import Foundation
public struct Suggestion: Decodable, Sendable {
public struct Suggestion: Decodable {
public let source: Source
public let account: Account
@ -17,7 +17,7 @@ public struct Suggestion: Decodable, Sendable {
}
extension Suggestion {
public enum Source: String, Decodable, Sendable {
public enum Source: String, Decodable {
case staff
case pastInteractions = "past_interactions"
case global

View File

@ -8,7 +8,7 @@
import Foundation
public enum Timeline: Equatable, Hashable, Sendable {
public enum Timeline: Equatable, Hashable {
case home
case `public`(local: Bool)
case tag(hashtag: String)

View File

@ -1,40 +0,0 @@
//
// 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"
}
}
}

View File

@ -8,7 +8,7 @@
import Foundation
struct WellKnown: Decodable, Sendable {
struct WellKnown: Decodable {
let links: [Link]
struct Link: Decodable {

View File

@ -8,7 +8,7 @@
import Foundation
protocol Body: Sendable {
protocol Body {
var mimeType: String? { get }
var data: Data? { get }
}
@ -76,7 +76,7 @@ struct FormDataBody: Body {
}
}
struct JsonBody<T: Encodable & Sendable>: Body {
struct JsonBody<T: Encodable>: Body {
let value: T
init(_ value: T) {

View File

@ -8,7 +8,7 @@
import Foundation
public struct Endpoint: ExpressibleByStringInterpolation, CustomStringConvertible, Sendable {
public struct Endpoint: ExpressibleByStringInterpolation, CustomStringConvertible {
let components: [Component]
public init(stringLiteral value: StringLiteralType) {
@ -54,7 +54,7 @@ public struct Endpoint: ExpressibleByStringInterpolation, CustomStringConvertibl
}
}
enum Component: Sendable {
enum Component {
case literal(String)
case interpolated(String)
}

View File

@ -8,7 +8,7 @@
import Foundation
public struct FormAttachment: Sendable {
public struct FormAttachment {
let mimeType: String
let data: Data
let fileName: String

View File

@ -8,7 +8,7 @@
import Foundation
public enum Method: Sendable {
public enum Method {
case get, post, put, patch, delete
}

View File

@ -8,7 +8,7 @@
import Foundation
struct Parameter: Sendable {
struct Parameter {
let name: String
let value: String?
}

View File

@ -8,7 +8,7 @@
import Foundation
public struct Request<ResultType: Decodable>: Sendable {
public struct Request<ResultType: Decodable> {
let method: Method
let endpoint: Endpoint
let body: Body

View File

@ -8,24 +8,13 @@
import Foundation
public enum RequestRange: Sendable {
public enum RequestRange {
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 {

View File

@ -8,6 +8,6 @@
import Foundation
public struct Empty: Decodable, Sendable {
public struct Empty: Decodable {
}

View File

@ -8,7 +8,7 @@
import Foundation
public struct Pagination: Sendable {
public struct Pagination {
public let older: RequestRange?
public let newer: RequestRange?
}

View File

@ -8,7 +8,7 @@
import Foundation
public enum Response<Result: Decodable & Sendable>: Sendable {
public enum Response<Result: Decodable> {
case success(Result, Pagination?)
case failure(Client.Error)
}

View File

@ -8,8 +8,7 @@
import Foundation
@MainActor
public final class CollapseState: Sendable {
public class CollapseState: Equatable {
public var collapsible: Bool?
public var collapsed: Bool?
@ -34,4 +33,8 @@ public final class CollapseState: Sendable {
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
}
}

View File

@ -14,7 +14,6 @@ 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
@ -52,7 +51,6 @@ 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 {

View File

@ -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:
return
fatalError()
}
controller.play(on: update.subBoard, column: update.column, row: update.row)
}

View File

@ -6,7 +6,6 @@
//
import Foundation
import Combine
public class GameController: ObservableObject {

View File

@ -20,9 +20,6 @@
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 */; };
@ -41,6 +38,8 @@
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 */; };
@ -103,6 +102,7 @@
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,7 +200,6 @@
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 */; };
@ -215,16 +214,11 @@
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 /* InlineTrendsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525C24A3E8F00054355A /* InlineTrendsViewController.swift */; };
D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525C24A3E8F00054355A /* SearchViewController.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 */; };
@ -257,6 +251,7 @@
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 */; };
@ -298,12 +293,6 @@
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 */; };
@ -330,16 +319,10 @@
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 */; };
@ -439,9 +422,6 @@
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>"; };
@ -459,6 +439,8 @@
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>"; };
@ -520,6 +502,7 @@
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>"; };
@ -600,7 +583,6 @@
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>"; };
@ -619,7 +601,6 @@
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>"; };
@ -635,16 +616,11 @@
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 /* InlineTrendsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTrendsViewController.swift; sourceTree = "<group>"; };
D68E525C24A3E8F00054355A /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.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>"; };
@ -677,6 +653,7 @@
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>"; };
@ -718,12 +695,6 @@
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>"; };
@ -757,16 +728,10 @@
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>"; };
@ -870,6 +835,8 @@
D611C2CC232DC5FC00C86A49 /* Hashtag Cell */ = {
isa = PBXGroup;
children = (
D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */,
D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */,
D6E77D08286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift */,
);
path = "Hashtag Cell";
@ -885,7 +852,6 @@
D627FF75217E923E00CC0648 /* DraftsManager.swift */,
D61F75AE293AF50C00C0B37F /* EditedFilter.swift */,
D65B4B532971F71D00DABDFB /* EditedReport.swift */,
D600891A29848289005B4D00 /* PinnedTimeline.swift */,
);
path = Models;
sourceTree = "<group>";
@ -907,7 +873,6 @@
D61F75A4293ABD6F00C0B37F /* EditFilterView.swift */,
D68A76E729527884001DA1B3 /* PinnedTimelinesView.swift */,
D68A76E9295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift */,
D600891E29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift */,
);
path = "Customize Timelines";
sourceTree = "<group>";
@ -942,7 +907,6 @@
isa = PBXGroup;
children = (
D6C82B4025C5BB7E0017F1E6 /* ExploreViewController.swift */,
D68E525C24A3E8F00054355A /* InlineTrendsViewController.swift */,
D6945C3323AC6431005C403C /* AddSavedHashtagViewController.swift */,
D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */,
D6945C3923AC75E2005C403C /* FindInstanceViewController.swift */,
@ -957,21 +921,16 @@
D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */,
D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */,
D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */,
D6C3F4FA299035650009FCFF /* TrendsViewController.swift */,
D68329EE299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift */,
D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */,
);
path = Explore;
sourceTree = "<group>";
};
D627944823A6AD5100D38C68 /* Local Predicate Statuses List */ = {
D627944823A6AD5100D38C68 /* Bookmarks */ = {
isa = PBXGroup;
children = (
D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */,
D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */,
D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */,
D627944923A6AD6100D38C68 /* BookmarksTableViewController.swift */,
);
path = "Local Predicate Statuses List";
path = Bookmarks;
sourceTree = "<group>";
};
D627944B23A9A02400D38C68 /* Lists */ = {
@ -988,7 +947,6 @@
children = (
D62D2425217ABF63005076CC /* UserActivityType.swift */,
D62D2421217AA7E1005076CC /* UserActivityManager.swift */,
D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */,
D62D2423217ABF3F005076CC /* NSUserActivity+Extensions.swift */,
D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */,
);
@ -1035,6 +993,7 @@
D6A3BC822321F69400FD64D5 /* Account List */,
D6B053A023BD2BED00A066FA /* Asset Picker */,
0411610522B457290030A9B7 /* Attachment Gallery */,
D627944823A6AD5100D38C68 /* Bookmarks */,
D641C787213DD862004B4513 /* Compose */,
D641C785213DD83B004B4513 /* Conversation */,
D6F2E960249E772F005846BB /* Crash Reporter */,
@ -1043,7 +1002,6 @@
D61F759729384D4200C0B37F /* Customize Timelines */,
D641C788213DD86D004B4513 /* Large Image */,
D627944B23A9A02400D38C68 /* Lists */,
D627944823A6AD5100D38C68 /* Local Predicate Statuses List */,
D641C782213DD7F0004B4513 /* Main */,
D6F6A555291F4F0C00F496A8 /* Mute */,
D641C786213DD852004B4513 /* Notifications */,
@ -1068,7 +1026,6 @@
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */,
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */,
D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */,
D6C3F5182991F5D60009FCFF /* TimelineJumpButton.swift */,
);
path = Timeline;
sourceTree = "<group>";
@ -1103,7 +1060,6 @@
D61DC84C28F500D200B82C6E /* ProfileViewController.swift */,
D61ABEF728EFC3F900B29151 /* ProfileStatusesViewController.swift */,
D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */,
D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */,
);
path = Profile;
sourceTree = "<group>";
@ -1114,7 +1070,6 @@
D65B4B6329771EFF00DABDFB /* ConversationViewController.swift */,
D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */,
D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */,
D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */,
);
path = Conversation;
sourceTree = "<group>";
@ -1201,7 +1156,6 @@
D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */,
D601FA60297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift */,
D6ADB6EB28EA73CB009924AB /* StatusContentContainer.swift */,
D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */,
);
path = Status;
sourceTree = "<group>";
@ -1213,7 +1167,6 @@
D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */,
D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */,
D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */,
D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */,
);
path = "Profile Header";
sourceTree = "<group>";
@ -1296,7 +1249,6 @@
D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */,
D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */,
D61F75872932DB6000C0B37F /* StatusSwipeAction.swift */,
D6D94954298963A900C59229 /* Colors.swift */,
);
path = Preferences;
sourceTree = "<group>";
@ -1326,8 +1278,6 @@
D63CC70F2911F1E4000E19DE /* UIScrollView+Top.swift */,
D61F758F29353B4300C0B37F /* FileManager+Size.swift */,
D61F75AC293AF39000C0B37F /* Filter+Helpers.swift */,
D6C3F5162991C1A00009FCFF /* View+AppListStyle.swift */,
D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -1439,6 +1389,7 @@
D6BC9DD8232D8BCA002CA326 /* Search */ = {
isa = PBXGroup;
children = (
D68E525C24A3E8F00054355A /* SearchViewController.swift */,
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */,
);
path = Search;
@ -1453,7 +1404,6 @@
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
D620483523D38075008A63EF /* ContentTextView.swift */,
D6D9498E298EB79400C59229 /* CopyableLable.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
D6969E9F240C8384002843CE /* EmojiLabel.swift */,
D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */,
@ -1506,8 +1456,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 */,
@ -1564,7 +1514,6 @@
D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */,
D6D4DDDB212518A200E1C4BB /* Info.plist */,
D60088F02980D938005B4D00 /* Tusker.storekit */,
D671A6BE299DA96100A81FEA /* Tusker-Bridging-Header.h */,
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
D61F75B6293C119700C0B37F /* Filterer.swift */,
@ -1612,7 +1561,6 @@
D6114E1627F8BB210080E273 /* VersionTests.swift */,
D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */,
D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */,
D600891C298482F0005B4D00 /* PinnedTimelineTests.swift */,
D6D4DDE6212518A200E1C4BB /* Info.plist */,
);
path = TuskerTests;
@ -1707,7 +1655,6 @@
D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */,
D65B4B6129771A3F00DABDFB /* FetchStatusService.swift */,
D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */,
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */,
);
path = API;
sourceTree = "<group>";
@ -1814,7 +1761,7 @@
TargetAttributes = {
D6D4DDCB212518A000E1C4BB = {
CreatedOnToolsVersion = 10.0;
LastSwiftMigration = 1420;
LastSwiftMigration = 1410;
};
D6D4DDDF212518A200E1C4BB = {
CreatedOnToolsVersion = 10.0;
@ -1869,6 +1816,7 @@
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 */,
@ -1976,9 +1924,7 @@
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 */,
@ -1986,10 +1932,8 @@
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 /* InlineTrendsViewController.swift in Sources */,
D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */,
D61F75BB293C183100C0B37F /* HTMLConverter.swift in Sources */,
D61F75A5293ABD6F00C0B37F /* EditFilterView.swift in Sources */,
D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */,
@ -2020,6 +1964,7 @@
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 */,
@ -2028,7 +1973,6 @@
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 */,
@ -2038,7 +1982,6 @@
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 */,
@ -2059,11 +2002,9 @@
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 */,
@ -2081,7 +2022,6 @@
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 */,
@ -2091,16 +2031,13 @@
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 */,
@ -2117,7 +2054,7 @@
D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */,
D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */,
D68A76EE295369C7001DA1B3 /* AcknowledgementsView.swift in Sources */,
D6C3F5192991F5D60009FCFF /* TimelineJumpButton.swift in Sources */,
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */,
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */,
D65B4B8B297879E900DABDFB /* AccountFollowsViewController.swift in Sources */,
@ -2134,7 +2071,6 @@
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 */,
@ -2166,7 +2102,6 @@
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 */,
@ -2197,7 +2132,6 @@
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 */,
@ -2213,9 +2147,7 @@
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 */,
@ -2236,7 +2168,6 @@
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 */,
@ -2272,11 +2203,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 */,
@ -2297,7 +2228,6 @@
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 */,
@ -2439,11 +2369,10 @@
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 = 76;
CURRENT_PROJECT_VERSION = 69;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2451,12 +2380,11 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2023.4;
MARKETING_VERSION = 2023.3;
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";
};
@ -2509,7 +2437,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 76;
CURRENT_PROJECT_VERSION = 69;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2518,7 +2446,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2023.4;
MARKETING_VERSION = 2023.3;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -2657,11 +2585,10 @@
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 = 76;
CURRENT_PROJECT_VERSION = 69;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2669,15 +2596,13 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2023.4;
MARKETING_VERSION = 2023.3;
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";
};
@ -2688,11 +2613,10 @@
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 = 76;
CURRENT_PROJECT_VERSION = 69;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2700,12 +2624,11 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2023.4;
MARKETING_VERSION = 2023.3;
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";
};
@ -2798,7 +2721,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 76;
CURRENT_PROJECT_VERSION = 69;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2807,7 +2730,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2023.4;
MARKETING_VERSION = 2023.3;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -2824,7 +2747,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 76;
CURRENT_PROJECT_VERSION = 69;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2833,7 +2756,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2023.4;
MARKETING_VERSION = 2023.3;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -2912,7 +2835,7 @@
repositoryURL = "https://github.com/getsentry/sentry-cocoa.git";
requirement = {
kind = upToNextMinorVersion;
minimumVersion = 8.0.0;
minimumVersion = 7.29.0;
};
};
D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */ = {

View File

@ -13,11 +13,11 @@ import Pachyderm
class CreateListService {
private let mastodonController: MastodonController
private let present: (UIViewController) -> Void
private let didCreateList: (@MainActor (List) async -> Void)?
private let didCreateList: (@MainActor (List) -> Void)?
private var createAction: UIAlertAction?
init(mastodonController: MastodonController, present: @escaping (UIViewController) -> Void, didCreateList: (@MainActor (List) async -> Void)?) {
init(mastodonController: MastodonController, present: @escaping (UIViewController) -> Void, didCreateList: (@MainActor (List) -> 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)
await self.didCreateList?(list)
self.didCreateList?(list)
} catch {
let alert = UIAlertController(title: "Error Creating List", message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))

View File

@ -48,7 +48,7 @@ class DeleteListService {
private func deleteList() async {
do {
let request = List.delete(list.id)
let request = List.delete(list)
_ = try await mastodonController.run(request)
mastodonController.deletedList(list)
} catch {

View File

@ -101,10 +101,6 @@ 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") {
@ -273,5 +269,5 @@ private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) {
"version": nodeInfo.software.version,
]
}
SentrySDK.addBreadcrumb(crumb)
SentrySDK.addBreadcrumb(crumb: crumb)
}

View File

@ -1,35 +0,0 @@
//
// 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)
}
}
}

View File

@ -31,16 +31,12 @@ class MastodonController: ObservableObject {
}
}
static func removeForAccount(_ account: LocalData.UserAccountInfo) {
all.removeValue(forKey: account)
}
static func resetAll() {
all = [:]
}
private let transient: Bool
private(set) nonisolated lazy var persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient)
private(set) lazy var persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient)
let instanceURL: URL
var accountInfo: LocalData.UserAccountInfo?
@ -110,7 +106,7 @@ class MastodonController: ObservableObject {
return response
}
func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
func run<Result>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
let response = await runResponse(request)
try Task.checkCancellation()
switch response {
@ -166,8 +162,6 @@ class MastodonController: ObservableObject {
loadAccountPreferences()
lists = loadCachedLists()
NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange, object: persistentContainer.persistentStoreCoordinator)
.receive(on: DispatchQueue.main)
.sink { [unowned self] _ in
@ -183,7 +177,7 @@ class MastodonController: ObservableObject {
_ = try await (ownAccount, ownInstance)
loadLists()
_ = await loadFilters()
async let _ = await loadFilters()
} catch {
Logging.general.error("MastodonController initialization failed: \(String(describing: error))")
}
@ -365,23 +359,6 @@ 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

View File

@ -11,13 +11,13 @@ import Pachyderm
@MainActor
class RenameListService {
private let list: ListProtocol
private let list: List
private let mastodonController: MastodonController
private let present: (UIViewController) -> Void
private var renameAction: UIAlertAction?
init(list: ListProtocol, mastodonController: MastodonController, present: @escaping (UIViewController) -> Void) {
init(list: List, 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.id, title: title)
let req = List.update(list, title: title)
let (list, _) = try await mastodonController.run(req)
mastodonController.renamedList(list)
} catch {

View File

@ -39,9 +39,9 @@ class OpenInSafariActivity: UIActivity {
static func completionHandler(navigator: TuskerNavigationDelegate, url: URL) -> UIActivityViewController.CompletionWithItemsHandler {
return { (activityType, _, _, _) in
if activityType == .openInSafari {
MainActor.runUnsafely {
navigator.selected(url: url, allowResolveStatuses: false, allowUniversalLinks: false)
}
let vc = SFSafariViewController(url: url)
vc.preferredControlTintColor = Preferences.shared.accentColor.color
navigator.show(vc)
}
}
}

View File

@ -26,10 +26,6 @@ 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!

View File

@ -20,7 +20,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
configureSentry()
swizzleStatusBar()
swizzlePresentationController()
AppShortcutItem.createItems(for: application)
@ -67,14 +66,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
options.enableSwizzling = false
// required to support releases/release health
options.enableAutoSessionTracking = true
options.enableWatchdogTerminationTracking = false
options.enableAutoPerformanceTracing = false
options.enableOutOfMemoryTracking = false
options.enableAutoPerformanceTracking = false
options.enableNetworkTracking = false
options.enableAppHangTracking = false
options.enableCoreDataTracing = false
options.enableCoreDataTracking = 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
@ -137,22 +135,17 @@ 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)
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)
}
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
}
if let exception {
SentrySDK.capture(exception: exception)
switch delegate.handleStatusBarTapped(xPosition: xPosition) {
case .stop:
return
case .continue:
original(self, selector, sender)
}
} as @convention(block) (UIStatusBarManager, AnyObject) -> Void)
originalIMP = class_replaceMethod(UIStatusBarManager.self, selector, imp, "v@:@")
@ -160,24 +153,4 @@ 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")
}
}
}

View File

@ -32,38 +32,46 @@ class ImageCache {
}
func get(_ url: URL, loadOriginal: Bool = false, completion: ((Data?, UIImage?) -> Void)?) -> Request? {
if !ImageCache.disableCaching,
let entry = try? cache.get(url.absoluteString, loadOriginal: loadOriginal) {
completion?(entry.data, entry.image)
return nil
} else {
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)
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)
return nil
} else {
let task = dataTask(url: url, completion: wrappedCompletion)
task.resume()
return task
}
}
func get(_ url: URL, loadOriginal: Bool = false) async -> (Data?, UIImage?) {
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)
// 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))
}
}
}
@ -73,28 +81,21 @@ class ImageCache {
guard !ImageCache.disableCaching else { return }
if !((try? cache.has(url.absoluteString)) ?? false) {
Task.detached(priority: .medium) {
_ = await self.fetch(url: url)
}
let task = dataTask(url: url, completion: nil)
task.resume()
}
}
private func fetch(url: URL) async -> FetchResult {
guard let (data, _) = try? await URLSession.shared.data(from: url) else {
return .none
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)
}
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? {
@ -113,12 +114,6 @@ class ImageCache {
return cache.disk?.getSizeInBytes()
}
typealias Request = Task<Void, Never>
enum FetchResult {
case data(Data)
case dataAndImage(Data, UIImage)
case none
}
typealias Request = URLSessionDataTask
}

View File

@ -77,7 +77,6 @@ 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 {
@ -85,14 +84,14 @@ class ImageDataCache {
}
let maxDimension = max(desiredPixelSize.width, desiredPixelSize.height)
let downsampleOptions: [CFString: Any] = [
let downsampleOptions = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimension
]
] as CFDictionary
if let downsampled = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions as CFDictionary) {
if let downsampled = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions) {
return UIImage(cgImage: downsampled)
} else {
return nil

View File

@ -25,7 +25,7 @@ public final class AccountPreferences: NSManagedObject {
@NSManaged var pinnedTimelinesData: Data?
@LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: [])
var pinnedTimelines: [PinnedTimeline]
var pinnedTimelines: [Timeline]
static func `default`(account: LocalData.UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences {
let prefs = AccountPreferences(context: context)

View File

@ -11,7 +11,7 @@ import CoreData
import Pachyderm
@objc(ListMO)
public final class ListMO: NSManagedObject, ListProtocol {
public final class ListMO: NSManagedObject {
@nonobjc public class func fetchRequest() -> NSFetchRequest<ListMO> {
return NSFetchRequest(entityName: "List")

View File

@ -211,7 +211,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
}
]
}
SentrySDK.addBreadcrumb(crumb)
SentrySDK.addBreadcrumb(crumb: crumb)
fatalError("Unable to save managed object context: \(String(describing: error))")
}
}
@ -545,8 +545,6 @@ 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 {

View File

@ -41,10 +41,6 @@ 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

View File

@ -1,55 +0,0 @@
//
// 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()
}
}

View File

@ -25,4 +25,23 @@ 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")!
}
}
}

View File

@ -1,48 +0,0 @@
//
// 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
}
}
}

View File

@ -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: @unchecked Sendable {
class FilterState {
static var unknown: FilterState { FilterState(state: .unknown) }
fileprivate var state: State

View File

@ -55,21 +55,7 @@ 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() {
@ -134,8 +120,6 @@ struct HTMLConverter {
}
return attributed
case is DataNode:
return NSAttributedString()
default:
fatalError("Unexpected node type \(type(of: node))")
}

View File

@ -18,7 +18,6 @@ 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
@ -38,16 +37,13 @@ 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.self, from: data)
let value = try decoder.decode(Box<Value>.self, from: data)
wrapper.value = value.value
wrapper.observation = instance.observe(wrapper.keyPath, changeHandler: { instance, _ in
var wrapper = instance[keyPath: storageKeyPath]
if wrapper.skipClearingOnNextUpdate {
wrapper.skipClearingOnNextUpdate = false
} else {
wrapper.removeCachedValue()
}
instance[keyPath: storageKeyPath] = wrapper
var updated = instance[keyPath: storageKeyPath]
updated.value = nil
updated.observation = nil
instance[keyPath: storageKeyPath] = updated
})
instance[keyPath: storageKeyPath] = wrapper
return value.value
@ -59,18 +55,12 @@ 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 {
@ -82,7 +72,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.
struct Box: Codable {
let value: Value
private struct Box<T: Codable>: Codable {
let value: T
}
}

View File

@ -11,7 +11,7 @@ import UIKit
struct MenuController {
static let composeCommand: UIKeyCommand = {
return UIKeyCommand(title: "Compose", action: #selector(MainSplitViewController.handleComposeKeyCommand), input: "n", modifierFlags: .command)
return UIKeyCommand(title: "Compose", action: #selector(MainSplitViewController.presentCompose), input: "n", modifierFlags: .command)
}()
static func refreshCommand(discoverabilityTitle: String?) -> UIKeyCommand {

View File

@ -1,129 +0,0 @@
//
// 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
}
}

View File

@ -13,24 +13,20 @@ import os
// to make the lock semantics more clear
@available(iOS, obsoleted: 16.0)
class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable> {
private let lock: any Lock<[Key: Value]>
private let lock: LockHolder<[AnyHashable: Any]>
init() {
if #available(iOS 16.0, *) {
self.lock = OSAllocatedUnfairLock(initialState: [:])
} else {
self.lock = UnfairLock(initialState: [:])
}
self.lock = LockHolder(initialState: [:])
}
subscript(key: Key) -> Value? {
get {
return lock.withLock { dict in
return try! lock.withLock { dict in
dict[key]
}
} as! Value?
}
set(value) {
_ = lock.withLock { dict in
_ = try! lock.withLock { dict in
dict[key] = value
}
}
@ -38,21 +34,40 @@ 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 lock.withLock { dict in
return try! lock.withLock { dict in
dict.removeValue(forKey: key)
}
} as! Value?
}
func contains(key: Key) -> Bool {
return lock.withLock { dict in
return try! 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]) throws -> R) rethrows -> R where R: Sendable {
return try lock.withLock { dict in
return try body(&dict)
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(_:)
}
}
}

View File

@ -1,104 +0,0 @@
//
// 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
}
}

View File

@ -38,14 +38,12 @@ 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
@ -65,7 +63,6 @@ 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)
@ -75,7 +72,6 @@ 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
@ -83,7 +79,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.hideTrends = try container.decodeIfPresent(Bool.self, forKey: .hideTrends) ?? false
self.hideDiscover = try container.decodeIfPresent(Bool.self, forKey: .hideDiscover) ?? false
self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType)
@ -95,14 +91,12 @@ 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)
@ -118,7 +112,6 @@ 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)
@ -128,7 +121,6 @@ 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)
@ -136,7 +128,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(hideTrends, forKey: .hideTrends)
try container.encode(hideDiscover, forKey: .hideDiscover)
try container.encode(statusContentType, forKey: .statusContentType)
@ -146,14 +138,12 @@ 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]
@ -179,7 +169,6 @@ 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
@ -190,7 +179,6 @@ 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
@ -199,7 +187,7 @@ class Preferences: Codable, ObservableObject {
@Published var defaultNotificationsMode = NotificationsMode.allNotifications
@Published var grayscaleImages = false
@Published var disableInfiniteScrolling = false
@Published var hideTrends = false
@Published var hideDiscover = false
// MARK: Advanced
@Published var statusContentType: StatusContentType = .plain
@ -211,14 +199,12 @@ 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
@ -235,7 +221,6 @@ class Preferences: Codable, ObservableObject {
case blurMediaBehindContentWarning
case automaticallyPlayGifs
case showUncroppedMediaInline
case showAttachmentBadges
case openLinksInApps
case useInAppSafari
@ -245,7 +230,6 @@ class Preferences: Codable, ObservableObject {
case oppositeCollapseKeywords
case confirmBeforeReblog
case timelineStateRestoration
case timelineSyncMode
case hideReblogsInTimelines
case hideRepliesInTimelines
@ -253,7 +237,7 @@ class Preferences: Codable, ObservableObject {
case defaultNotificationsType
case grayscaleImages
case disableInfiniteScrolling
case hideTrends = "hideDiscover"
case hideDiscover
case statusContentType
@ -399,10 +383,3 @@ extension Preferences {
}
}
}
extension Preferences {
enum TimelineSyncMode: String, Codable {
case mastodon
case icloud
}
}

View File

@ -128,9 +128,7 @@ 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
MainActor.runUnsafely {
container.navigationDelegate.showMoreOptions(forStatus: status.id, source: .view(container))
}
container.navigationDelegate.showMoreOptions(forStatus: status.id, source: .view(container))
completion(true)
}
// bold to more closesly match other action symbols
@ -168,9 +166,7 @@ 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
MainActor.runUnsafely {
container.navigationDelegate.selected(url: status.url!, allowUniversalLinks: false)
}
container.navigationDelegate.selected(url: status.url!, allowUniversalLinks: false)
completion(true)
}
action.image = UIImage(systemName: "safari")

View File

@ -49,7 +49,7 @@ class SavedDataManager: Codable {
var changed = false
if let hashtags = savedHashtags[accountID] {
let objects: [[String: Any]] = hashtags.map {
let objects = hashtags.map {
["url": $0.url, "name": $0.name]
}
let hashtagsReq = NSBatchInsertRequest(entity: SavedHashtag.entity(), objects: objects)

View File

@ -9,14 +9,10 @@
import UIKit
import Pachyderm
class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate {
class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
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) {
@ -74,7 +70,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel
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:
@ -86,10 +82,10 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel
return NotificationsPageViewController(initialMode: mode, mastodonController: mastodonController)
case .search:
return InlineTrendsViewController(mastodonController: mastodonController)
return SearchViewController(mastodonController: mastodonController)
case .bookmarks:
return BookmarksViewController(mastodonController: mastodonController)
return BookmarksTableViewController(mastodonController: mastodonController)
case .myProfile:
return MyProfileViewController(mastodonController: mastodonController)
@ -116,6 +112,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel
}
@objc private func themePrefChanged() {
applyAppearancePreferences()
window?.overrideUserInterfaceStyle = Preferences.shared.theme
window?.tintColor = Preferences.shared.accentColor.color
}
}

View File

@ -9,12 +9,10 @@
import UIKit
import Combine
class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate {
class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var rootViewController: TuskerRootViewController? { nil }
private var cancellables = Set<AnyCancellable>()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
@ -102,7 +100,8 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
}
@objc private func themePrefChanged() {
applyAppearancePreferences()
window?.overrideUserInterfaceStyle = Preferences.shared.theme
window?.tintColor = Preferences.shared.accentColor.color
}
}

View File

@ -64,15 +64,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
stateRestorationLogger.info("MainSceneDelegate.scene(_:continue:) called with \(userActivity.activityType, privacy: .public)")
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))
_ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene))
}
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
@ -177,16 +169,10 @@ 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 {
doRestoreActivity(context: StateRestorationUserActivityHandlingContext(root: rootViewController!))
rootViewController?.restoreActivity(activity)
} else if activity.activityType != UserActivityType.mainScene.rawValue {
doRestoreActivity(context: ActiveAccountUserActivityHandlingContext(isHandoff: false, root: rootViewController!))
} else {
stateRestorationLogger.fault("MainSceneDelegate launched with non-restorable activity \(activity.activityType, privacy: .public)")
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!))
}
}
} else {
@ -218,9 +204,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
} else {
direction = .none
}
container.setRoot(newRoot, for: account, animating: direction)
container.setRoot(newRoot, animating: direction)
} else {
window!.rootViewController = AccountSwitchingContainerViewController(root: newRoot, for: account)
window!.rootViewController = AccountSwitchingContainerViewController(root: newRoot)
}
}
@ -228,7 +214,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
guard let account = window?.windowScene?.session.mastodonController?.accountInfo else {
return
}
LogoutService(accountInfo: account).run()
LocalData.shared.removeAccount(account)
if LocalData.shared.onboardingComplete {
activateAccount(LocalData.shared.accounts.first!, animated: false)
} else {
@ -257,7 +243,8 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
}
@objc func themePrefChanged() {
applyAppearancePreferences()
window?.overrideUserInterfaceStyle = Preferences.shared.theme
window?.tintColor = Preferences.shared.accentColor.color
}
func showAddAccount() {

View File

@ -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,19 +27,4 @@ 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)
}
}
}

View File

@ -11,8 +11,6 @@ import Pachyderm
class AccountFollowsListViewController: UIViewController, CollectionViewController {
private static let pageSize = 40
let accountID: String
let mastodonController: MastodonController
let mode: AccountFollowsViewController.Mode
@ -41,8 +39,7 @@ class AccountFollowsListViewController: UIViewController, CollectionViewControll
}
override func loadView() {
var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.backgroundColor = .appBackground
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in
guard let item = self.dataSource.itemIdentifier(for: indexPath) else {
return sectionConfig
@ -66,16 +63,6 @@ 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()
@ -102,12 +89,12 @@ class AccountFollowsListViewController: UIViewController, CollectionViewControll
}
}
private nonisolated func request(for range: RequestRange) -> Request<[Account]> {
private func request(for range: RequestRange) -> Request<[Account]> {
switch mode {
case .following:
return Account.getFollowing(accountID, range: range.withCount(Self.pageSize))
return Account.getFollowing(accountID, range: range)
case .followers:
return Account.getFollowers(accountID, range: range.withCount(Self.pageSize))
return Account.getFollowers(accountID, range: range)
}
}

View File

@ -31,8 +31,7 @@ class AccountListViewController: UIViewController, CollectionViewController {
}
override func loadView() {
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.backgroundColor = .appGroupedBackground
let config = UICollectionLayoutListConfiguration(appearance: .grouped)
let layout = UICollectionViewCompositionalLayout.list(using: config)
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self

View File

@ -72,8 +72,7 @@ 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 = .appBackground
collectionView.backgroundColor = .appBackground
view.backgroundColor = .systemBackground
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(donePressed))

View File

@ -34,7 +34,6 @@ 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 {

View File

@ -0,0 +1,196 @@
//
// 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)
}
}

View File

@ -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(.appBackground))
.background(Color(.systemBackground))
.environment(\.colorScheme, .dark)
.edgesIgnoringSafeArea(.bottom)
.withSheetDetentsIfAvailable()

View File

@ -236,7 +236,6 @@ struct ComposeAutocompleteEmojisView: View {
CustomEmojiImageView(emoji: emoji)
.frame(height: emojiSize)
}
.accessibilityLabel(emoji.shortcode)
}
} header: {
if !section.isEmpty {
@ -272,7 +271,6 @@ struct ComposeAutocompleteEmojisView: View {
.foregroundColor(Color(UIColor.label))
}
}
.accessibilityLabel(emoji.shortcode)
.frame(height: emojiSize)
}
.animation(.linear(duration: 0.2), value: emojis)
@ -295,7 +293,6 @@ struct ComposeAutocompleteEmojisView: View {
.aspectRatio(contentMode: .fit)
.rotationEffect(expanded ? .zero : .degrees(180))
}
.accessibilityLabel(expanded ? "Collapse" : "Expand")
.frame(width: 20, height: 20)
}

View File

@ -15,17 +15,15 @@ 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, maxLength: Int? = nil, becomeFirstResponder: Binding<Bool>? = nil, focusNextView: Binding<Bool>? = nil) {
init(text: Binding<String>, placeholder: String, 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
@ -76,7 +74,6 @@ struct ComposeEmojiTextField: UIViewRepresentable {
} else {
uiView.text = text
}
context.coordinator.maxLength = maxLength
context.coordinator.didChange = didChange
context.coordinator.didEndEditing = didEndEditing
context.coordinator.focusNextView = focusNextView
@ -98,7 +95,6 @@ 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>?
@ -118,14 +114,6 @@ 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)

View File

@ -20,7 +20,6 @@ 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,14 +30,6 @@ 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 {
@ -76,15 +67,9 @@ struct ComposePollView: View {
.frame(height: 44 * CGFloat(poll.options.count))
Button(action: self.addOption) {
Label {
Text("Add Option")
} icon: {
Image(systemName: "plus")
.foregroundColor(.accentColor)
}
Label("Add Option", systemImage: "plus")
}
.buttonStyle(.borderless)
.disabled(!canAddOption)
HStack {
MenuPicker(selection: $poll.multiple, options: [
@ -111,7 +96,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.appFill : Color(white: 0.95)
colorScheme == .dark ? Color(UIColor.secondarySystemBackground) : Color(white: 0.95)
}
private var buttonBackgroundColor: Color {
@ -170,8 +155,6 @@ 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)
@ -190,8 +173,8 @@ struct ComposePollOption: View {
}
private var textField: some View {
var field = ComposeEmojiTextField(text: $option.text, placeholder: "Option \(optionIndex + 1)", maxLength: mastodonController.instance?.pollsConfiguration?.maxCharactersPerOption)
return field.backgroundColor(.appBackground)
var field = ComposeEmojiTextField(text: $option.text, placeholder: "Option \(optionIndex + 1)")
return field.backgroundColor(.systemBackground)
}
private func removeOption() {
@ -216,7 +199,7 @@ struct ComposePollOption: View {
.cornerRadius(radiusFraction * size)
Rectangle()
.foregroundColor(Color(UIColor.appBackground))
.foregroundColor(Color(UIColor.systemBackground))
.frame(width: innerSize, height: innerSize)
.cornerRadius(radiusFraction * innerSize)
}

View File

@ -54,7 +54,6 @@ struct ComposeToolbar: View {
.font(.system(size: imageSize))
.padding(5)
.hoverEffect()
.transition(.opacity.animation(.linear(duration: 0.2)))
}
if let currentInput = uiState.currentInput,
@ -75,11 +74,16 @@ 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)
@ -115,6 +119,10 @@ struct ComposeToolbar: View {
uiState.currentInput?.beginAutocompletingEmoji()
}
private func draftsButtonPressed() {
uiState.isShowingDraftsList = true
}
private func formatAction(_ format: StatusFormat) -> () -> Void {
{
uiState.currentInput?.applyFormat(format)

View File

@ -94,10 +94,6 @@ 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()
@ -128,7 +124,7 @@ struct ComposeView: View {
}
})
.sheet(isPresented: $uiState.isShowingDraftsList) {
DraftsRepresentable(currentDraft: draft, mastodonController: mastodonController)
DraftsView(currentDraft: draft, mastodonController: mastodonController)
}
.actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet)
.alert(isPresented: $isShowingPostErrorAlert) {
@ -173,13 +169,11 @@ 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(
@ -190,7 +184,6 @@ struct ComposeView: View {
)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
.listRowBackground(Color.appBackground)
}
MainComposeTextView(
@ -199,20 +192,17 @@ 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()
@ -249,25 +239,16 @@ struct ComposeView: View {
}
}
@ViewBuilder
private var postButton: some View {
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")
Button {
Task {
await self.postStatus()
}
} label: {
Text("Post")
}
.keyboardShortcut(.return, modifiers: .command)
.disabled(!postButtonEnabled)
}
private func cancel() {
@ -329,7 +310,7 @@ struct ComposeView: View {
}
}
extension View {
private extension View {
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {

View File

@ -8,21 +8,6 @@
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
@ -64,10 +49,8 @@ struct DraftsView: View {
.map { visibleDrafts[$0] }
.forEach { draftsManager.remove($0) }
}
.appGroupedListRowBackground()
}
.listStyle(.plain)
.appGroupedListBackground(container: DraftsRepresentable.UIViewControllerType.self)
.navigationTitle(Text("Drafts"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {

View File

@ -9,20 +9,39 @@
import SwiftUI
import Pachyderm
struct MainComposeTextView: View, PlaceholderViewProvider {
struct MainComposeTextView: View {
@ObservedObject var draft: Draft
@State private var placeholder: PlaceholderView = Self.placeholderView()
@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?")
}()
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) {
colorScheme == .dark ? Color.appFill : Color(uiColor: .secondarySystemBackground)
Color(UIColor.secondarySystemBackground)
if draft.text.isEmpty {
placeholder
@ -48,38 +67,6 @@ struct MainComposeTextView: View, PlaceholderViewProvider {
}
}
}
@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 {
@ -111,7 +98,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
if context.coordinator.skipSettingTextOnNextUpdate {
context.coordinator.skipSettingTextOnNextUpdate = false
} else {
context.coordinator.skipNextAutocompleteUpdate = true
uiView.text = text
}
@ -199,7 +185,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
var caretScrollPositionAnimator: UIViewPropertyAnimator?
var skipSettingTextOnNextUpdate = false
var skipNextAutocompleteUpdate = false
var toolbarElements: [ComposeUIState.ToolbarElement] {
[.emojiPicker, .formattingButtons]
@ -339,10 +324,6 @@ 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 {

View File

@ -9,9 +9,18 @@
import UIKit
import Pachyderm
class ConversationCollectionViewController: UIViewController, CollectionViewController, RefreshableViewController {
class ConversationNode {
let status: StatusMO
var children: [ConversationNode]
init(status: StatusMO) {
self.status = status
self.children = []
}
}
class ConversationCollectionViewController: UIViewController, CollectionViewController {
private unowned let conversationViewController: ConversationViewController
private let mastodonController: MastodonController
private let mainStatusID: String
private let mainStatusState: CollapseState
@ -23,12 +32,11 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
}
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
init(for mainStatusID: String, state: CollapseState, conversationViewController: ConversationViewController) {
init(for mainStatusID: String, state: CollapseState, mastodonController: MastodonController) {
self.mainStatusID = mainStatusID
self.mainStatusState = state
self.statusIDToScrollToOnLoad = mainStatusID
self.conversationViewController = conversationViewController
self.mastodonController = conversationViewController.mastodonController
self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil)
}
@ -39,7 +47,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
override func loadView() {
var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.backgroundColor = .appSecondaryBackground
config.backgroundColor = .secondarySystemBackground
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
}
@ -47,21 +55,17 @@ 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
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.bottomSeparatorVisibility = lastInSection ? .visible : .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 peeking through the edges
// background color always peaking through the edges
let layout = UICollectionViewCompositionalLayout.list(using: config)
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
// something about the autoresizing mask breaks resizing the vc
@ -70,11 +74,6 @@ 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()
}
@ -100,7 +99,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
}
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case let .status(id: id, node: _, state: state, prevLink: prevLink, nextLink: nextLink):
case let .status(id: id, state: state, prevLink: prevLink, nextLink: nextLink):
if id == self.mainStatusID {
return collectionView.dequeueConfiguredReusableCell(using: mainStatusCell, for: indexPath, item: (id, state, prevLink))
} else {
@ -124,33 +123,45 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
loadViewIfNeeded()
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.ancestors, .mainStatus])
snapshot.appendSections([.statuses])
if status.inReplyToID != nil {
snapshot.appendItems([.loadingIndicator], toSection: .ancestors)
snapshot.appendItems([.loadingIndicator], 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)
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState, prevLink: status.inReplyToID != nil, nextLink: false)
snapshot.appendItems([mainStatusItem], toSection: .statuses)
dataSource.apply(snapshot, animatingDifferences: false)
}
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)
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)
}
snapshot.appendItems(parentItems, toSection: .ancestors)
snapshot.insertItems(parentItems, beforeItem: mainStatusItem)
snapshot.reloadItems([mainStatusItem])
// convert sub-threads into items for section and add to snapshot
self.addFlattenedChildThreadsToSnapshot(tree.descendants, mainStatus: mainStatus, snapshot: &snapshot)
// 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)
}
self.dataSource.apply(snapshot, animatingDifferences: false) {
let item: Item
@ -160,7 +171,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
@ -176,6 +187,54 @@ 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
@ -189,7 +248,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, node: node, state: .unknown, prevLink: false, nextLink: !node.children.isEmpty)], toSection: section)
snapshot.appendItems([.status(id: node.status.id, state: .unknown, prevLink: false, nextLink: !node.children.isEmpty)], toSection: section)
var currentNode = node
while true {
@ -212,7 +271,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
}
currentNode = next
snapshot.appendItems([.status(id: next.status.id, node: next, state: .unknown, prevLink: true, nextLink: !next.children.isEmpty)], toSection: section)
snapshot.appendItems([.status(id: next.status.id, state: .unknown, prevLink: true, nextLink: !next.children.isEmpty)], toSection: section)
}
}
}
@ -221,7 +280,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
var snapshot = dataSource.snapshot()
var cellsToMask: [StatusCollectionViewCell] = []
for item in snapshot.itemIdentifiers {
guard case .status(id: _, node: _, state: let state, prevLink: _, nextLink: _) = item,
guard case .status(id: _, state: let state, prevLink: _, nextLink: _) = item,
state.collapsible == true else {
continue
}
@ -248,31 +307,21 @@ 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 ancestors
case mainStatus
case statuses
case childThread(firstStatusID: String)
}
enum Item: Hashable {
case status(id: String, node: ConversationNode, state: CollapseState, prevLink: Bool, nextLink: Bool)
case status(id: String, 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, node: _, state: _, prevLink: aPrev, nextLink: aNext), .status(id: b, node: _, state: _, prevLink: bPrev, nextLink: bNext)):
case let (.status(id: a, state: _, prevLink: aPrev, nextLink: aNext), .status(id: b, 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
@ -285,7 +334,7 @@ extension ConversationCollectionViewController {
func hash(into hasher: inout Hasher) {
switch self {
case let .status(id: id, node: _, state: _, prevLink: prevLink, nextLink: nextLink):
case let .status(id: id, state: _, prevLink: prevLink, nextLink: nextLink):
hasher.combine(0)
hasher.combine(id)
hasher.combine(prevLink)
@ -306,7 +355,7 @@ extension ConversationCollectionViewController {
extension ConversationCollectionViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
switch dataSource.itemIdentifier(for: indexPath) {
case .status(id: let id, node: _, state: _, prevLink: _, nextLink: _):
case .status(id: let id, state: _, prevLink: _, nextLink: _):
return id != mainStatusID
case .expandThread(childThreads: _, inline: _):
return true
@ -321,25 +370,12 @@ extension ConversationCollectionViewController: UICollectionViewDelegate {
break
case .loadingIndicator:
break
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 .status(id: let id, state: let state, _, _):
selected(status: id, state: state.copy())
case .expandThread(childThreads: let childThreads, inline: _):
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)
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)
conv.statusIDToScrollToOnLoad = childThreads.first!.status.id
conv.showStatusesAutomatically = showStatusesAutomatically
show(conv)
@ -347,34 +383,6 @@ 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()
}

View File

@ -1,101 +0,0 @@
//
// 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)
}
}
}
}

View File

@ -77,14 +77,6 @@ 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")
}
@ -94,7 +86,7 @@ class ConversationViewController: UIViewController {
title = NSLocalizedString("Conversation", comment: "conversation screen title")
view.backgroundColor = .appSecondaryBackground
view.backgroundColor = .secondarySystemBackground
collapseBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "eye.fill")!, style: .plain, target: self, action: #selector(toggleCollapseButtonPressed))
updateVisibilityBarButtonItem()
@ -123,15 +115,9 @@ class ConversationViewController: UIViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
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()
}
Task {
if case .unloaded = state {
await loadMainStatus()
}
}
}
@ -156,20 +142,9 @@ class ConversationViewController: UIViewController {
// MARK: Loading
@MainActor
private func loadMainStatus() async {
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")
guard let mainStatusID = await resolveStatusIfNecessary() else {
return
}
@MainActor
@ -191,7 +166,7 @@ class ConversationViewController: UIViewController {
Task {
await doLoadMainStatus()
}
mainStatusLoaded(cached)
await mainStatusLoaded(cached)
} else {
// otherwise, show a loading indicator while loading the main status
let indicator = UIActivityIndicatorView(style: .medium)
@ -199,110 +174,77 @@ class ConversationViewController: UIViewController {
state = .loading(indicator)
if let status = await doLoadMainStatus() {
mainStatusLoaded(status)
await mainStatusLoaded(status)
}
}
}
@MainActor
private func resolveStatus(url: URL) async -> String? {
let indicator = UIActivityIndicatorView(style: .medium)
indicator.startAnimating()
state = .loading(indicator)
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()
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)
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
}
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
mode = .localID(status.id)
return status.id
} catch {
state = .unableToResolve(error)
return nil
}
}
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)
@MainActor
private func mainStatusLoaded(_ mainStatus: StatusMO) async {
let vc = ConversationCollectionViewController(for: mainStatus.id, state: mainStatusState, mastodonController: mastodonController)
vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad ?? mainStatus.id
vc.showStatusesAutomatically = showStatusesAutomatically
vc.addMainStatus(mainStatus)
state = .displaying(vc)
if case .preloaded(let tree) = mode {
vc.addTree(tree, mainStatus: mainStatus)
} else {
Task { @MainActor in
await loadTree(for: mainStatus)
}
}
await loadContext(for: mainStatus)
}
@MainActor
private func loadTree(for mainStatus: StatusMO) async {
guard case .displaying(_) = state,
let context = await loadContext(for: mainStatus) else {
private func loadContext(for mainStatus: StatusMO) async {
guard case .displaying(_) = state 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)
return context
guard case .displaying(let vc) = state else {
return
}
await vc.addContext(context, for: mainStatus)
} catch {
guard case .displaying(_) = state else {
return nil
return
}
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?.loadTree(for: mainStatus)
await self?.loadContext(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
@ -399,7 +341,6 @@ extension ConversationViewController {
enum Mode {
case localID(String)
case resolve(URL)
case preloaded(ConversationTree)
}
}
@ -425,17 +366,6 @@ 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