Compare commits

..

No commits in common. "0f6492a051f4acb6f46ff5c867c7a524b6347437" and "a9a518c6c18229acbdeac4bbcc66e87772273dd9" have entirely different histories.

94 changed files with 573 additions and 722 deletions

View File

@ -18,23 +18,19 @@ class ActionViewController: UIViewController {
super.viewDidLoad() super.viewDidLoad()
findURLFromWebPage { (components) in findURLFromWebPage { (components) in
DispatchQueue.main.async { if let components = components {
if let components {
self.searchForURLInApp(components) self.searchForURLInApp(components)
} else { } else {
self.findURLItem { (components) in self.findURLItem { (components) in
if let components { if let components = components {
DispatchQueue.main.async {
self.searchForURLInApp(components) self.searchForURLInApp(components)
} }
} }
} }
} }
} }
}
}
private func findURLFromWebPage(completion: @escaping @Sendable (URLComponents?) -> Void) { private func findURLFromWebPage(completion: @escaping (URLComponents?) -> Void) {
for item in extensionContext!.inputItems as! [NSExtensionItem] { for item in extensionContext!.inputItems as! [NSExtensionItem] {
for provider in item.attachments! { for provider in item.attachments! {
guard provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) else { guard provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) else {
@ -58,7 +54,7 @@ class ActionViewController: UIViewController {
completion(nil) completion(nil)
} }
private func findURLItem(completion: @escaping @Sendable (URLComponents?) -> Void) { private func findURLItem(completion: @escaping (URLComponents?) -> Void) {
for item in extensionContext!.inputItems as! [NSExtensionItem] { for item in extensionContext!.inputItems as! [NSExtensionItem] {
for provider in item.attachments! { for provider in item.attachments! {
guard provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) else { guard provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) else {

View File

@ -15,8 +15,7 @@ public protocol ComposeMastodonContext {
var instanceFeatures: InstanceFeatures { get } var instanceFeatures: InstanceFeatures { get }
func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?)
func getCustomEmojis(completion: @escaping ([Emoji]) -> Void)
func getCustomEmojis() async -> [Emoji]
@MainActor @MainActor
func searchCachedAccounts(query: String) -> [AccountProtocol] func searchCachedAccounts(query: String) -> [AccountProtocol]

View File

@ -44,7 +44,11 @@ class AutocompleteEmojisController: ViewController {
@MainActor @MainActor
private func queryChanged(_ query: String) async { private func queryChanged(_ query: String) async {
var emojis = await composeController.mastodonController.getCustomEmojis() var emojis = await withCheckedContinuation { continuation in
composeController.mastodonController.getCustomEmojis {
continuation.resume(returning: $0)
}
}
guard !Task.isCancelled else { guard !Task.isCancelled else {
return return
} }

View File

@ -7,7 +7,6 @@
import UIKit import UIKit
@MainActor
public protocol DuckableViewController: UIViewController { public protocol DuckableViewController: UIViewController {
func duckableViewControllerShouldDuck() -> DuckAttemptAction func duckableViewControllerShouldDuck() -> DuckAttemptAction

View File

@ -10,7 +10,7 @@ import Foundation
import Combine import Combine
import Pachyderm import Pachyderm
public final class InstanceFeatures: ObservableObject { public class InstanceFeatures: ObservableObject {
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; (pleroma|akkoma) (.*)\\)", options: .caseInsensitive) private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; (pleroma|akkoma) (.*)\\)", options: .caseInsensitive)
private let _featuresUpdated = PassthroughSubject<Void, Never>() private let _featuresUpdated = PassthroughSubject<Void, Never>()

View File

@ -12,7 +12,7 @@ import WebURL
/** /**
The base Mastodon API client. The base Mastodon API client.
*/ */
public struct Client: Sendable { public class Client {
public typealias Callback<Result: Decodable> = (Response<Result>) -> Void public typealias Callback<Result: Decodable> = (Response<Result>) -> Void
@ -20,6 +20,8 @@ public struct Client: Sendable {
let session: URLSession let session: URLSession
public var accessToken: String? public var accessToken: String?
public var appID: String?
public var clientID: String? public var clientID: String?
public var clientSecret: String? public var clientSecret: String?
@ -59,11 +61,9 @@ public struct Client: Sendable {
return encoder return encoder
}() }()
public init(baseURL: URL, accessToken: String? = nil, clientID: String? = nil, clientSecret: String? = nil, session: URLSession = .shared) { public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
self.baseURL = baseURL self.baseURL = baseURL
self.accessToken = accessToken self.accessToken = accessToken
self.clientID = clientID
self.clientSecret = clientSecret
self.session = session self.session = session
} }
@ -150,7 +150,14 @@ public struct Client: Sendable {
"scopes" => scopes.scopeString, "scopes" => scopes.scopeString,
"website" => website?.absoluteString "website" => website?.absoluteString
])) ]))
run(request, completion: completion) run(request) { result in
defer { completion(result) }
guard case let .success(application, _) = result else { return }
self.appID = application.id
self.clientID = application.clientID
self.clientSecret = application.clientSecret
}
} }
public func getAccessToken(authorizationCode: String, redirectURI: String, scopes: [Scope], completion: @escaping Callback<LoginSettings>) { public func getAccessToken(authorizationCode: String, redirectURI: String, scopes: [Scope], completion: @escaping Callback<LoginSettings>) {
@ -162,7 +169,12 @@ public struct Client: Sendable {
"redirect_uri" => redirectURI, "redirect_uri" => redirectURI,
"scope" => scopes.scopeString, "scope" => scopes.scopeString,
])) ]))
run(request, completion: completion) run(request) { result in
defer { completion(result) }
guard case let .success(loginSettings, _) = result else { return }
self.accessToken = loginSettings.accessToken
}
} }
public func revokeAccessToken() async throws { public func revokeAccessToken() async throws {
@ -186,16 +198,21 @@ public struct Client: Sendable {
}) })
} }
public func nodeInfo() async throws -> NodeInfo { public func nodeInfo(completion: @escaping Callback<NodeInfo>) {
let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo") let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo")
let wellKnownResults = try await run(wellKnown).0 run(wellKnown) { result in
if let url = wellKnownResults.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }), switch result {
case let .failure(error):
completion(.failure(error))
case let .success(wellKnown, _):
if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
let href = WebURL(url.href), let href = WebURL(url.href),
href.host == WebURL(self.baseURL)?.host { href.host == WebURL(self.baseURL)?.host {
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path)) let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
return try await run(nodeInfo).0 self.run(nodeInfo, completion: completion)
} else { }
throw NodeInfoError.noWellKnownLink }
} }
} }
@ -583,15 +600,4 @@ extension Client {
case invalidModel(Swift.Error) case invalidModel(Swift.Error)
case mastodonError(Int, String) case mastodonError(Int, String)
} }
enum NodeInfoError: LocalizedError {
case noWellKnownLink
var errorDescription: String? {
switch self {
case .noWellKnownLink:
return "No well-known link"
}
}
}
} }

View File

@ -7,7 +7,7 @@
import Foundation import Foundation
public enum SearchOperatorType: String, CaseIterable, Equatable, Sendable { public enum SearchOperatorType: String, CaseIterable, Equatable {
case has case has
case `is` case `is`
case language case language

View File

@ -7,7 +7,7 @@
import Foundation import Foundation
public struct StatusEdit: Decodable, Sendable { public struct StatusEdit: Decodable {
public let content: String public let content: String
public let spoilerText: String public let spoilerText: String
public let sensitive: Bool public let sensitive: Bool
@ -28,10 +28,10 @@ public struct StatusEdit: Decodable, Sendable {
case emojis case emojis
} }
public struct Poll: Decodable, Sendable { public struct Poll: Decodable {
public let options: [Option] public let options: [Option]
public struct Option: Decodable, Sendable { public struct Option: Decodable {
public let title: String public let title: String
} }
} }

View File

@ -7,7 +7,7 @@
import Foundation import Foundation
public struct StatusSource: Decodable, Sendable { public struct StatusSource: Decodable {
public let id: String public let id: String
public let text: String public let text: String
public let spoilerText: String public let spoilerText: String

View File

@ -49,7 +49,7 @@ public class InstanceSelector {
} }
public extension InstanceSelector { public extension InstanceSelector {
struct Instance: Codable, Sendable { struct Instance: Codable {
public let domain: String public let domain: String
public let description: String public let description: String
public let proxiedThumbnailURL: URL public let proxiedThumbnailURL: URL

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
import Pachyderm import Pachyderm
public enum PostVisibility: Codable, Hashable, CaseIterable, Sendable { public enum PostVisibility: Codable, Hashable, CaseIterable {
case serverDefault case serverDefault
case visibility(Visibility) case visibility(Visibility)
@ -59,7 +59,6 @@ public enum ReplyVisibility: Codable, Hashable, CaseIterable {
public static var allCases: [ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) } public static var allCases: [ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) }
@MainActor
public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility { public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility {
switch self { switch self {
case .sameAsPost: case .sameAsPost:

View File

@ -12,14 +12,12 @@ import Combine
public final class Preferences: Codable, ObservableObject { public final class Preferences: Codable, ObservableObject {
@MainActor
public static var shared: Preferences = load() public static var shared: Preferences = load()
private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
private static var appGroupDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")! private static var appGroupDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
private static var archiveURL = appGroupDirectory.appendingPathComponent("preferences").appendingPathExtension("plist") private static var archiveURL = appGroupDirectory.appendingPathComponent("preferences").appendingPathExtension("plist")
@MainActor
public static func save() { public static func save() {
let encoder = PropertyListEncoder() let encoder = PropertyListEncoder()
let data = try? encoder.encode(shared) let data = try? encoder.encode(shared)
@ -35,7 +33,6 @@ public final class Preferences: Codable, ObservableObject {
return Preferences() return Preferences()
} }
@MainActor
public static func migrate(from url: URL) -> Result<Void, any Error> { public static func migrate(from url: URL) -> Result<Void, any Error> {
do { do {
try? FileManager.default.removeItem(at: archiveURL) try? FileManager.default.removeItem(at: archiveURL)

View File

@ -9,7 +9,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
public enum StatusSwipeAction: String, Codable, Hashable, CaseIterable, Sendable { public enum StatusSwipeAction: String, Codable, Hashable, CaseIterable {
case reply case reply
case favorite case favorite
case reblog case reblog

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable { public struct UserAccountInfo: Equatable, Hashable, Identifiable {
public let id: String public let id: String
public let instanceURL: URL public let instanceURL: URL
public let clientID: String public let clientID: String

View File

@ -12,7 +12,7 @@ import UserAccounts
import InstanceFeatures import InstanceFeatures
import Combine import Combine
final class ShareMastodonContext: ComposeMastodonContext, ObservableObject, Sendable { class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
let accountInfo: UserAccountInfo? let accountInfo: UserAccountInfo?
let client: Client let client: Client
let instanceFeatures: InstanceFeatures let instanceFeatures: InstanceFeatures
@ -20,9 +20,7 @@ final class ShareMastodonContext: ComposeMastodonContext, ObservableObject, Send
@MainActor @MainActor
private var customEmojis: [Emoji]? private var customEmojis: [Emoji]?
@MainActor @Published var ownAccount: Account?
@Published
private(set) var ownAccount: Account?
init(accountInfo: UserAccountInfo) { init(accountInfo: UserAccountInfo) {
self.accountInfo = accountInfo self.accountInfo = accountInfo
@ -31,7 +29,16 @@ final class ShareMastodonContext: ComposeMastodonContext, ObservableObject, Send
Task { @MainActor in Task { @MainActor in
async let instance = try? await run(Client.getInstanceV1()).0 async let instance = try? await run(Client.getInstanceV1()).0
async let nodeInfo = try? await client.nodeInfo() async let nodeInfo: NodeInfo? = await withCheckedContinuation({ continuation in
self.client.nodeInfo { response in
switch response {
case .success(let nodeInfo, _):
continuation.resume(returning: nodeInfo)
case .failure(_):
continuation.resume(returning: nil)
}
}
})
guard let instance = await instance else { return } guard let instance = await instance else { return }
self.instanceFeatures.update(instance: InstanceInfo(v1: instance), nodeInfo: await nodeInfo) self.instanceFeatures.update(instance: InstanceInfo(v1: instance), nodeInfo: await nodeInfo)
} }
@ -59,13 +66,15 @@ final class ShareMastodonContext: ComposeMastodonContext, ObservableObject, Send
} }
@MainActor @MainActor
func getCustomEmojis() async -> [Emoji] { func getCustomEmojis(completion: @escaping ([Emoji]) -> Void) {
if let customEmojis { if let customEmojis {
return customEmojis completion(customEmojis)
} else { } else {
Task.detached { @MainActor in
let emojis = (try? await self.run(Client.getCustomEmoji()).0) ?? [] let emojis = (try? await self.run(Client.getCustomEmoji()).0) ?? []
self.customEmojis = emojis self.customEmojis = emojis
return emojis completion(emojis)
}
} }
} }

View File

@ -54,7 +54,6 @@ struct SwitchAccountContainerView: View {
} }
} }
@MainActor
private struct AccountButtonLabel: View { private struct AccountButtonLabel: View {
static let urlSession = URLSession(configuration: .ephemeral) static let urlSession = URLSession(configuration: .ephemeral)

View File

@ -314,7 +314,6 @@
D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; }; D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; };
D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */; }; D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */; };
D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */; }; D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */; };
D6DEBA8D2B6579830008629A /* MainThreadBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DEBA8C2B6579830008629A /* MainThreadBox.swift */; };
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */; }; D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */; };
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */; }; D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */; };
D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* Weak.swift */; }; D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* Weak.swift */; };
@ -723,7 +722,6 @@
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.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>"; }; 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>"; }; D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfilesViewController.swift; sourceTree = "<group>"; };
D6DEBA8C2B6579830008629A /* MainThreadBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainThreadBox.swift; sourceTree = "<group>"; };
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelationshipMO.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>"; }; 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>"; }; D6DFC69F242C4CCC00ACC392 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = "<group>"; };
@ -1504,7 +1502,6 @@
D61F75BC293D099600C0B37F /* Lazy.swift */, D61F75BC293D099600C0B37F /* Lazy.swift */,
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */, D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D61DC84528F498F200B82C6E /* Logging.swift */, D61DC84528F498F200B82C6E /* Logging.swift */,
D6DEBA8C2B6579830008629A /* MainThreadBox.swift */,
D6B81F432560390300F6E31D /* MenuController.swift */, D6B81F432560390300F6E31D /* MenuController.swift */,
D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */, D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */, D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
@ -2152,7 +2149,6 @@
D601FA61297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift in Sources */, D601FA61297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift in Sources */,
D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */, D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */,
D601FA5F297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift in Sources */, D601FA5F297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift in Sources */,
D6DEBA8D2B6579830008629A /* MainThreadBox.swift in Sources */,
D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */, D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */,
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */, D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */,
D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */, D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */,

View File

@ -15,16 +15,16 @@ import InstanceFeatures
import Sentry import Sentry
#endif #endif
import ComposeUI import ComposeUI
import OSLog
private let oauthScopes = [Scope.read, .write, .follow] private let oauthScopes = [Scope.read, .write, .follow]
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "MastodonController")
final class MastodonController: ObservableObject, Sendable { class MastodonController: ObservableObject {
@MainActor
static private(set) var all = [UserAccountInfo: MastodonController]() static private(set) var all = [UserAccountInfo: MastodonController]()
@available(*, message: "do something less dumb")
static var first: MastodonController { all.first!.value }
@MainActor @MainActor
static func getForAccount(_ account: UserAccountInfo) -> MastodonController { static func getForAccount(_ account: UserAccountInfo) -> MastodonController {
if let controller = all[account] { if let controller = all[account] {
@ -36,12 +36,10 @@ final class MastodonController: ObservableObject, Sendable {
} }
} }
@MainActor
static func removeForAccount(_ account: UserAccountInfo) { static func removeForAccount(_ account: UserAccountInfo) {
all.removeValue(forKey: account) all.removeValue(forKey: account)
} }
@MainActor
static func resetAll() { static func resetAll() {
all = [:] all = [:]
} }
@ -50,31 +48,26 @@ final class MastodonController: ObservableObject, Sendable {
nonisolated let persistentContainer: MastodonCachePersistentStore// = MastodonCachePersistentStore(for: accountInfo, transient: transient) nonisolated let persistentContainer: MastodonCachePersistentStore// = MastodonCachePersistentStore(for: accountInfo, transient: transient)
let instanceURL: URL let instanceURL: URL
let accountInfo: UserAccountInfo? var accountInfo: UserAccountInfo?
@MainActor var accountPreferences: AccountPreferences!
private(set) var accountPreferences: AccountPreferences!
private(set) var client: Client! let client: Client!
let instanceFeatures = InstanceFeatures() let instanceFeatures = InstanceFeatures()
@MainActor @Published private(set) var account: AccountMO? @Published private(set) var account: AccountMO?
@MainActor @Published private(set) var instance: InstanceV1? @Published private(set) var instance: InstanceV1?
@MainActor @Published private var instanceV2: InstanceV2? @Published private var instanceV2: InstanceV2?
@MainActor @Published private(set) var instanceInfo: InstanceInfo! @Published private(set) var instanceInfo: InstanceInfo!
@MainActor @Published private(set) var nodeInfo: NodeInfo! @Published private(set) var nodeInfo: NodeInfo!
@MainActor @Published private(set) var lists: [List] = [] @Published private(set) var lists: [List] = []
@MainActor @Published private(set) var customEmojis: [Emoji]? @Published private(set) var customEmojis: [Emoji]?
@MainActor @Published private(set) var followedHashtags: [FollowedHashtag] = [] @Published private(set) var followedHashtags: [FollowedHashtag] = []
@MainActor @Published private(set) var filters: [FilterMO] = [] @Published private(set) var filters: [FilterMO] = []
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@MainActor private var pendingOwnInstanceRequestCallbacks = [(Result<InstanceV1, Client.Error>) -> Void]()
private var fetchOwnInstanceTask: Task<InstanceV1, any Error>? private var ownInstanceRequest: URLSessionTask?
@MainActor
private var fetchOwnAccountTask: Task<MainThreadBox<AccountMO>, any Error>?
@MainActor
private var fetchCustomEmojisTask: Task<[Emoji], Never>?
var loggedIn: Bool { var loggedIn: Bool {
accountInfo != nil accountInfo != nil
@ -229,36 +222,21 @@ final class MastodonController: ObservableObject, Sendable {
Task { Task {
do { do {
_ = try await getOwnAccount() async let ownAccount = try getOwnAccount()
} catch { async let ownInstance = try getOwnInstance()
logger.error("Fetch own account failed: \(String(describing: error))")
} _ = try await (ownAccount, ownInstance)
}
let instanceTask = Task {
do {
_ = try await getOwnInstance()
if instanceFeatures.hasMastodonVersion(4, 0, 0) { if instanceFeatures.hasMastodonVersion(4, 0, 0) {
_ = try? await getOwnInstanceV2() async let _ = try? getOwnInstanceV2()
}
} catch {
logger.error("Fetch instance failed: \(String(describing: error))")
}
} }
Task { loadLists()
_ = await instanceTask.value
await loadLists()
}
Task {
_ = await instanceTask.value
_ = await loadFilters() _ = await loadFilters()
}
Task {
_ = await instanceTask.value
await loadServerPreferences() await loadServerPreferences()
} catch {
Logging.general.error("MastodonController initialization failed: \(String(describing: error))")
}
} }
} }
@ -272,19 +250,19 @@ final class MastodonController: ObservableObject, Sendable {
} }
} }
@MainActor func getOwnAccount(completion: ((Result<AccountMO, Client.Error>) -> Void)? = nil) {
func getOwnAccount() async throws -> AccountMO {
if let account { if let account {
return account completion?(.success(account))
} else if let fetchOwnAccountTask {
return try await fetchOwnAccountTask.value.value
} else { } else {
let task = Task { let request = Client.getSelfAccount()
let account = try await run(Client.getSelfAccount()).0 run(request) { response in
switch response {
case let .failure(error):
completion?(.failure(error))
let context = persistentContainer.viewContext case let .success(account, _):
// this closure is declared separately so we can tell the compiler it's Sendable let context = self.persistentContainer.viewContext
let performBlock: @MainActor @Sendable () -> MainThreadBox<AccountMO> = { context.perform {
let accountMO: AccountMO let accountMO: AccountMO
if let existing = self.persistentContainer.account(for: account.id, in: context) { if let existing = self.persistentContainer.account(for: account.id, in: context) {
accountMO = existing accountMO = existing
@ -292,73 +270,111 @@ final class MastodonController: ObservableObject, Sendable {
} else { } else {
accountMO = self.persistentContainer.addOrUpdateSynchronously(account: account, in: context) accountMO = self.persistentContainer.addOrUpdateSynchronously(account: account, in: context)
} }
// TODO: is AccountMO.active used anywhere?
accountMO.active = true accountMO.active = true
self.account = accountMO self.account = accountMO
return MainThreadBox(value: accountMO) completion?(.success(accountMO))
}
} }
// it's safe to remove the MainActor annotation, because this is the view context
return await context.perform(unsafeBitCast(performBlock, to: (@Sendable () -> MainThreadBox<AccountMO>).self))
} }
fetchOwnAccountTask = task
return try await task.value.value
} }
} }
func getOwnInstance(completion: (@Sendable (InstanceV1) -> Void)? = nil) { func getOwnAccount() async throws -> AccountMO {
Task.detached { if let account = account {
let ownInstance = try? await self.getOwnInstance() return account
if let ownInstance { } else {
completion?(ownInstance) return try await withCheckedThrowingContinuation({ continuation in
self.getOwnAccount { result in
continuation.resume(with: result)
}
})
}
}
func getOwnInstance(completion: ((InstanceV1) -> Void)? = nil) {
getOwnInstanceInternal(retryAttempt: 0) {
if case let .success(instance) = $0 {
completion?(instance)
} }
} }
} }
@MainActor @MainActor
func getOwnInstance() async throws -> InstanceV1 { func getOwnInstance() async throws -> InstanceV1 {
if let instance { return try await withCheckedThrowingContinuation({ continuation in
return instance getOwnInstanceInternal(retryAttempt: 0) { result in
} else if let fetchOwnInstanceTask { continuation.resume(with: result)
return try await fetchOwnInstanceTask.value
} else {
let task = Task {
let instance = try await retrying("Fetch Own Instance") {
try await run(Client.getInstanceV1()).0
} }
})
}
private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result<InstanceV1, Client.Error>) -> Void)?) {
// this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks
assert(Thread.isMainThread)
if let instance = self.instance {
completion?(.success(instance))
} else {
if let completion = completion {
pendingOwnInstanceRequestCallbacks.append(completion)
}
if ownInstanceRequest == nil {
let request = Client.getInstanceV1()
ownInstanceRequest = run(request) { (response) in
switch response {
case .failure(let error):
let delay: DispatchTimeInterval
switch retryAttempt {
case 0:
delay = .seconds(1)
case 1:
delay = .seconds(5)
case 2:
delay = .seconds(30)
case 3:
delay = .seconds(60)
default:
// if we've failed four times, just give up :/
for completion in self.pendingOwnInstanceRequestCallbacks {
completion(.failure(error))
}
self.pendingOwnInstanceRequestCallbacks = []
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
// completion is nil because in this invocation of getOwnInstanceInternal we've already added it to the pending callbacks array
self.getOwnInstanceInternal(retryAttempt: retryAttempt + 1, completion: nil)
}
case let .success(instance, _):
DispatchQueue.main.async {
self.ownInstanceRequest = nil
self.instance = instance self.instance = instance
Task { for completion in self.pendingOwnInstanceRequestCallbacks {
await fetchNodeInfo() completion(.success(instance))
} }
self.pendingOwnInstanceRequestCallbacks = []
return instance
} }
fetchOwnInstanceTask = task
return try await task.value
} }
} }
@MainActor client.nodeInfo { result in
private func fetchNodeInfo() async { switch result {
if let nodeInfo = try? await client.nodeInfo() { case let .failure(error):
print("Unable to get node info: \(error)")
case let .success(nodeInfo, _):
DispatchQueue.main.async {
self.nodeInfo = nodeInfo self.nodeInfo = nodeInfo
} }
} }
private func retrying<T: Sendable>(_ label: StaticString, action: @Sendable () async throws -> T) async throws -> T {
for attempt in 0..<4 {
do {
return try await action()
} catch {
let seconds = UInt64(truncating: pow(2, attempt) as NSNumber)
logger.error("\(label, privacy: .public) failed, waiting \(seconds, privacy: .public)s before retrying. Reason: \(String(describing: error))")
try! await Task.sleep(nanoseconds: seconds * NSEC_PER_SEC)
} }
} }
return try await action() }
} }
@MainActor
private func getOwnInstanceV2() async throws { private func getOwnInstanceV2() async throws {
self.instanceV2 = try await client.run(Client.getInstanceV2()).0 self.instanceV2 = try await client.run(Client.getInstanceV2()).0
} }
@ -409,35 +425,33 @@ final class MastodonController: ObservableObject, Sendable {
} }
} }
@MainActor func getCustomEmojis(completion: @escaping ([Emoji]) -> Void) {
func getCustomEmojis() async -> [Emoji] { if let emojis = self.customEmojis {
if let customEmojis { completion(emojis)
return customEmojis
} else if let fetchCustomEmojisTask {
return await fetchCustomEmojisTask.value
} else { } else {
let task = Task { let request = Client.getCustomEmoji()
let emojis = (try? await run(Client.getCustomEmoji()).0) ?? [] run(request) { (response) in
customEmojis = emojis if case let .success(emojis, _) = response {
return emojis DispatchQueue.main.async {
self.customEmojis = emojis
}
completion(emojis)
} else {
completion([])
}
} }
fetchCustomEmojisTask = task
return await task.value
} }
} }
private func loadLists() async { private func loadLists() {
let req = Client.getLists() let req = Client.getLists()
guard let (lists, _) = try? await run(req) else { run(req) { response in
return if case .success(let lists, _) = response {
DispatchQueue.main.async {
self.lists = lists.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
} }
let sorted = lists.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title)) let context = self.persistentContainer.backgroundContext
await MainActor.run { context.perform {
self.lists = sorted
}
let context = persistentContainer.backgroundContext
await context.perform {
for list in lists { for list in lists {
if let existing = try? context.fetch(ListMO.fetchRequest(id: list.id)).first { if let existing = try? context.fetch(ListMO.fetchRequest(id: list.id)).first {
existing.updateFrom(apiList: list) existing.updateFrom(apiList: list)
@ -448,6 +462,8 @@ final class MastodonController: ObservableObject, Sendable {
self.persistentContainer.save(context: context) self.persistentContainer.save(context: context)
} }
} }
}
}
private func loadCachedLists() -> [List] { private func loadCachedLists() -> [List] {
let req = ListMO.fetchRequest() let req = ListMO.fetchRequest()
@ -532,7 +548,6 @@ final class MastodonController: ObservableObject, Sendable {
filters = (try? persistentContainer.viewContext.fetch(FilterMO.fetchRequest())) ?? [] filters = (try? persistentContainer.viewContext.fetch(FilterMO.fetchRequest())) ?? []
} }
@MainActor
func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil) -> Draft { func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil) -> Draft {
var acctsToMention = [String]() var acctsToMention = [String]()
@ -585,7 +600,6 @@ final class MastodonController: ObservableObject, Sendable {
) )
} }
@MainActor
func createDraft(editing status: StatusMO, source: StatusSource) -> Draft { func createDraft(editing status: StatusMO, source: StatusSource) -> Draft {
precondition(status.id == source.id) precondition(status.id == source.id)
let draft = DraftsPersistentContainer.shared.createEditDraft( let draft = DraftsPersistentContainer.shared.createEditDraft(

View File

@ -21,7 +21,7 @@ typealias Preferences = TuskerPreferences.Preferences
let stateRestorationLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "StateRestoration") let stateRestorationLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "StateRestoration")
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "AppDelegate") private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "AppDelegate")
@main @UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

View File

@ -11,19 +11,14 @@ import UIKit
#if os(visionOS) #if os(visionOS)
private let imageScale: CGFloat = 2 private let imageScale: CGFloat = 2
#else #else
@MainActor
private let imageScale = UIScreen.main.scale private let imageScale = UIScreen.main.scale
#endif #endif
final class ImageCache: @unchecked Sendable { class ImageCache {
@MainActor
static let avatars = ImageCache(name: "Avatars", memoryExpiry: .seconds(60 * 60), diskExpiry: .seconds(60 * 60 * 24 * 7), desiredSize: CGSize(width: 50, height: 50)) static let avatars = ImageCache(name: "Avatars", memoryExpiry: .seconds(60 * 60), diskExpiry: .seconds(60 * 60 * 24 * 7), desiredSize: CGSize(width: 50, height: 50))
@MainActor
static let headers = ImageCache(name: "Headers", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60 * 24 * 7)) static let headers = ImageCache(name: "Headers", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60 * 24 * 7))
@MainActor
static let attachments = ImageCache(name: "Attachments", memoryExpiry: .seconds(60 * 2)) static let attachments = ImageCache(name: "Attachments", memoryExpiry: .seconds(60 * 2))
@MainActor
static let emojis = ImageCache(name: "Emojis", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60 * 24 * 7)) static let emojis = ImageCache(name: "Emojis", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60 * 24 * 7))
#if DEBUG #if DEBUG
@ -35,7 +30,6 @@ final class ImageCache: @unchecked Sendable {
private let cache: ImageDataCache private let cache: ImageDataCache
private let desiredPixelSize: CGSize? private let desiredPixelSize: CGSize?
@MainActor
init(name: String, memoryExpiry: CacheExpiry, diskExpiry: CacheExpiry? = nil, desiredSize: CGSize? = nil) { init(name: String, memoryExpiry: CacheExpiry, diskExpiry: CacheExpiry? = nil, desiredSize: CGSize? = nil) {
// todo: might not always want to use UIScreen.main for this, e.g. Catalyst? // todo: might not always want to use UIScreen.main for this, e.g. Catalyst?
let pixelSize = desiredSize?.applying(.init(scaleX: imageScale, y: imageScale)) let pixelSize = desiredSize?.applying(.init(scaleX: imageScale, y: imageScale))
@ -43,7 +37,7 @@ final class ImageCache: @unchecked Sendable {
self.cache = ImageDataCache(name: name, memoryExpiry: memoryExpiry, diskExpiry: diskExpiry, storeOriginalDataInMemory: diskExpiry == nil, desiredPixelSize: pixelSize) self.cache = ImageDataCache(name: name, memoryExpiry: memoryExpiry, diskExpiry: diskExpiry, storeOriginalDataInMemory: diskExpiry == nil, desiredPixelSize: pixelSize)
} }
func get(_ url: URL, loadOriginal: Bool = false, completion: (@Sendable (Data?, UIImage?) -> Void)?) -> Request? { func get(_ url: URL, loadOriginal: Bool = false, completion: ((Data?, UIImage?) -> Void)?) -> Request? {
if !ImageCache.disableCaching, if !ImageCache.disableCaching,
let entry = try? cache.get(url.absoluteString, loadOriginal: loadOriginal) { let entry = try? cache.get(url.absoluteString, loadOriginal: loadOriginal) {
completion?(entry.data, entry.image) completion?(entry.data, entry.image)
@ -53,7 +47,7 @@ final class ImageCache: @unchecked Sendable {
} }
} }
func getFromSource(_ url: URL, completion: (@Sendable (Data?, UIImage?) -> Void)?) -> Request? { func getFromSource(_ url: URL, completion: ((Data?, UIImage?) -> Void)?) -> Request? {
return Task.detached(priority: .userInitiated) { return Task.detached(priority: .userInitiated) {
let result = await self.fetch(url: url) let result = await self.fetch(url: url)
switch result { switch result {

View File

@ -550,19 +550,14 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
} }
} }
} }
// Can't capture vars in concurrently-executing closure
let hashtags = changedHashtags
let instances = changedInstances
let timelinePositions = changedTimelinePositions
let accountPrefs = changedAccountPrefs
DispatchQueue.main.async { DispatchQueue.main.async {
if hashtags { if changedHashtags {
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil) NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil)
} }
if instances { if changedInstances {
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil) NotificationCenter.default.post(name: .savedInstancesChanged, object: nil)
} }
for id in timelinePositions { for id in changedTimelinePositions {
guard let timelinePosition = try? self.viewContext.existingObject(with: id) as? TimelinePosition else { guard let timelinePosition = try? self.viewContext.existingObject(with: id) as? TimelinePosition else {
continue continue
} }
@ -570,7 +565,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
timelinePosition.changedRemotely() timelinePosition.changedRemotely()
NotificationCenter.default.post(name: .timelinePositionChanged, object: timelinePosition) NotificationCenter.default.post(name: .timelinePositionChanged, object: timelinePosition)
} }
if accountPrefs { if changedAccountPrefs {
NotificationCenter.default.post(name: .accountPreferencesChangedRemotely, object: nil) NotificationCenter.default.post(name: .accountPreferencesChangedRemotely, object: nil)
} }
} }

View File

@ -9,7 +9,7 @@
import Foundation import Foundation
/* /*
Copied from https://github.com/ChimeHQ/ConcurrencyPlus/blob/daf69ed837fa04d4ba666f5a99378cf1815f0dab/Sources/ConcurrencyPlus/MainActor%2BUnsafe.swift Copied from https://github.com/ChimeHQ/ConcurrencyPlus/blob/fe3b3fd5436b196d8c5211ab2cc4b69fc35524fe/Sources/ConcurrencyPlus/MainActor%2BUnsafe.swift
Copyright (c) 2022, Chime Copyright (c) 2022, Chime
All rights reserved. All rights reserved.
@ -46,23 +46,10 @@ public extension MainActor {
/// 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. /// 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. /// It will crash if run on any non-main thread.
@_unavailableFromAsync @MainActor(unsafe)
static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T { static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T {
if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) {
return try MainActor.assumeIsolated(body)
}
dispatchPrecondition(condition: .onQueue(.main)) dispatchPrecondition(condition: .onQueue(.main))
return try withoutActuallyEscaping(body) { fn in
try unsafeBitCast(fn, to: (() throws -> T).self)()
}
}
@_unavailableFromAsync return try body()
@available(*, deprecated, message: "Tool of last resort, do not use this.")
static func runUnsafelyMaybeIntroducingDataRace<T>(_ body: @MainActor () throws -> T) rethrows -> T {
return try withoutActuallyEscaping(body) { fn in
try unsafeBitCast(fn, to: (() throws -> T).self)()
}
} }
} }

View File

@ -10,7 +10,6 @@ import SwiftUI
import Combine import Combine
extension View { extension View {
@MainActor
@ViewBuilder @ViewBuilder
func appGroupedListBackground(container: UIAppearanceContainer.Type, applyBackground: Bool = true) -> some View { func appGroupedListBackground(container: UIAppearanceContainer.Type, applyBackground: Bool = true) -> some View {
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {

View File

@ -15,10 +15,7 @@ struct ImageGrayscalifier {
private static let cache = NSCache<NSURL, UIImage>() private static let cache = NSCache<NSURL, UIImage>()
static func convertIfNecessary(url: URL?, image: UIImage) -> UIImage? { static func convertIfNecessary(url: URL?, image: UIImage) -> UIImage? {
let grayscale = MainActor.runUnsafelyMaybeIntroducingDataRace { if Preferences.shared.grayscaleImages,
Preferences.shared.grayscaleImages
}
if grayscale,
let source = image.cgImage { let source = image.cgImage {
// todo: should this return the original image if conversion fails? // todo: should this return the original image if conversion fails?
return convert(url: url, cgImage: source) return convert(url: url, cgImage: source)
@ -27,18 +24,6 @@ struct ImageGrayscalifier {
} }
} }
static func convertIfNecessary(url: URL?, image: UIImage) async -> UIImage? {
let grayscale = await MainActor.run {
Preferences.shared.grayscaleImages
}
if grayscale,
let source = image.cgImage {
return await convert(url: url, cgImage: source)
} else {
return image
}
}
static func convert(url: URL?, image: UIImage) -> UIImage? { static func convert(url: URL?, image: UIImage) -> UIImage? {
if let url, if let url,
let cached = cache.object(forKey: url as NSURL) { let cached = cache.object(forKey: url as NSURL) {
@ -50,21 +35,6 @@ struct ImageGrayscalifier {
return doConvert(CIImage(cgImage: cgImage), url: url) return doConvert(CIImage(cgImage: cgImage), url: url)
} }
static func convert(url: URL?, image: UIImage) async -> UIImage? {
if let url,
let cached = cache.object(forKey: url as NSURL) {
return cached
}
guard let cgImage = image.cgImage else {
return nil
}
return await withCheckedContinuation { continuation in
queue.async {
continuation.resume(returning: doConvert(CIImage(cgImage: cgImage), url: url))
}
}
}
static func convert(url: URL?, data: Data) -> UIImage? { static func convert(url: URL?, data: Data) -> UIImage? {
if let url = url, if let url = url,
let cached = cache.object(forKey: url as NSURL) { let cached = cache.object(forKey: url as NSURL) {
@ -86,18 +56,6 @@ struct ImageGrayscalifier {
return doConvert(CIImage(cgImage: cgImage), url: url) return doConvert(CIImage(cgImage: cgImage), url: url)
} }
static func convert(url: URL?, cgImage: CGImage) async -> UIImage? {
if let url = url,
let cached = cache.object(forKey: url as NSURL) {
return cached
}
return await withCheckedContinuation { continuation in
queue.async {
continuation.resume(returning: doConvert(CIImage(cgImage: cgImage), url: url))
}
}
}
private static func doConvert(_ source: CIImage, url: URL?) -> UIImage? { private static func doConvert(_ source: CIImage, url: URL?) -> UIImage? {
guard let filter = CIFilter(name: "CIColorMonochrome") else { guard let filter = CIFilter(name: "CIColorMonochrome") else {
return nil return nil

View File

@ -1,23 +0,0 @@
//
// MainThreadBox.swift
// Tusker
//
// Created by Shadowfacts on 1/27/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import Foundation
struct MainThreadBox<T>: @unchecked Sendable {
private let _value: T
@MainActor
var value: T {
_value
}
@MainActor
init(value: T) {
self._value = value
}
}

View File

@ -8,7 +8,6 @@
import UIKit import UIKit
@MainActor
struct MenuController { struct MenuController {
static let composeCommand: UIKeyCommand = { static let composeCommand: UIKeyCommand = {

View File

@ -13,7 +13,7 @@ import os
// to make the lock semantics more clear // to make the lock semantics more clear
@available(iOS, obsoleted: 16.0) @available(iOS, obsoleted: 16.0)
@available(visionOS 1.0, *) @available(visionOS 1.0, *)
final class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable>: @unchecked Sendable { class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable> {
#if os(visionOS) #if os(visionOS)
private let lock = OSAllocatedUnfairLock(initialState: [Key: Value]()) private let lock = OSAllocatedUnfairLock(initialState: [Key: Value]())
#else #else

View File

@ -11,7 +11,6 @@ import Pachyderm
import TuskerPreferences import TuskerPreferences
extension StatusSwipeAction { extension StatusSwipeAction {
@MainActor
func createAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? { func createAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? {
switch self { switch self {
case .reply: case .reply:
@ -30,7 +29,6 @@ extension StatusSwipeAction {
} }
} }
@MainActor
protocol StatusSwipeActionContainer: UIView { protocol StatusSwipeActionContainer: UIView {
var mastodonController: MastodonController! { get } var mastodonController: MastodonController! { get }
var navigationDelegate: any TuskerNavigationDelegate { get } var navigationDelegate: any TuskerNavigationDelegate { get }
@ -42,7 +40,6 @@ protocol StatusSwipeActionContainer: UIView {
func performReplyAction() func performReplyAction()
} }
@MainActor
private func createReplyAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? { private func createReplyAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? {
guard container.mastodonController.loggedIn else { guard container.mastodonController.loggedIn else {
return nil return nil
@ -56,7 +53,6 @@ private func createReplyAction(status: StatusMO, container: StatusSwipeActionCon
return action return action
} }
@MainActor
private func createFavoriteAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? { private func createFavoriteAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? {
guard container.mastodonController.loggedIn else { guard container.mastodonController.loggedIn else {
return nil return nil
@ -73,7 +69,6 @@ private func createFavoriteAction(status: StatusMO, container: StatusSwipeAction
return action return action
} }
@MainActor
private func createReblogAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? { private func createReblogAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? {
guard container.mastodonController.loggedIn, guard container.mastodonController.loggedIn,
container.canReblog else { container.canReblog else {
@ -91,7 +86,6 @@ private func createReblogAction(status: StatusMO, container: StatusSwipeActionCo
return action return action
} }
@MainActor
private func createShareAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction { private func createShareAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction {
let action = UIContextualAction(style: .normal, title: "Share") { [unowned container] _, _, completion in let action = UIContextualAction(style: .normal, title: "Share") { [unowned container] _, _, completion in
MainActor.runUnsafely { MainActor.runUnsafely {
@ -106,7 +100,6 @@ private func createShareAction(status: StatusMO, container: StatusSwipeActionCon
return action return action
} }
@MainActor
private func createBookmarkAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? { private func createBookmarkAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? {
guard container.mastodonController.loggedIn else { guard container.mastodonController.loggedIn else {
return nil return nil
@ -131,10 +124,11 @@ private func createBookmarkAction(status: StatusMO, container: StatusSwipeAction
return action return action
} }
@MainActor
private func createOpenInSafariAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction { private func createOpenInSafariAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction {
let action = UIContextualAction(style: .normal, title: "Open in Safari") { [unowned container] _, _, completion in 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) completion(true)
} }
action.image = UIImage(systemName: "safari") action.image = UIImage(systemName: "safari")

View File

@ -10,14 +10,10 @@ import Foundation
import Pachyderm import Pachyderm
import CoreData import CoreData
// TODO: remove this class eventually
class SavedDataManager: Codable { class SavedDataManager: Codable {
@MainActor
private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
@MainActor
private static var archiveURL = SavedDataManager.documentsDirectory.appendingPathComponent("saved_data").appendingPathExtension("plist") private static var archiveURL = SavedDataManager.documentsDirectory.appendingPathComponent("saved_data").appendingPathExtension("plist")
@MainActor
static func load() -> SavedDataManager? { static func load() -> SavedDataManager? {
let decoder = PropertyListDecoder() let decoder = PropertyListDecoder()
if let data = try? Data(contentsOf: archiveURL), if let data = try? Data(contentsOf: archiveURL),
@ -27,7 +23,6 @@ class SavedDataManager: Codable {
return nil return nil
} }
@MainActor
static func destroy() throws { static func destroy() throws {
try FileManager.default.removeItem(at: archiveURL) try FileManager.default.removeItem(at: archiveURL)
} }
@ -44,7 +39,6 @@ class SavedDataManager: Codable {
return s return s
} }
@MainActor
private func save() { private func save() {
let encoder = PropertyListEncoder() let encoder = PropertyListEncoder()
let data = try? encoder.encode(self) let data = try? encoder.encode(self)
@ -52,6 +46,8 @@ class SavedDataManager: Codable {
} }
func migrateToCoreData(accountID: String, context: NSManagedObjectContext) throws { func migrateToCoreData(accountID: String, context: NSManagedObjectContext) throws {
var changed = false
if let hashtags = savedHashtags[accountID] { if let hashtags = savedHashtags[accountID] {
let objects: [[String: Any]] = hashtags.map { let objects: [[String: Any]] = hashtags.map {
["url": $0.url, "name": $0.name] ["url": $0.url, "name": $0.name]
@ -59,6 +55,7 @@ class SavedDataManager: Codable {
let hashtagsReq = NSBatchInsertRequest(entity: SavedHashtag.entity(), objects: objects) let hashtagsReq = NSBatchInsertRequest(entity: SavedHashtag.entity(), objects: objects)
try context.execute(hashtagsReq) try context.execute(hashtagsReq)
savedHashtags.removeValue(forKey: accountID) savedHashtags.removeValue(forKey: accountID)
changed = true
} }
if let instances = savedInstances[accountID] { if let instances = savedInstances[accountID] {
@ -68,6 +65,11 @@ class SavedDataManager: Codable {
let instancesReq = NSBatchInsertRequest(entity: SavedInstance.entity(), objects: objects) let instancesReq = NSBatchInsertRequest(entity: SavedInstance.entity(), objects: objects)
try context.execute(instancesReq) try context.execute(instancesReq)
savedInstances.removeValue(forKey: accountID) savedInstances.removeValue(forKey: accountID)
changed = true
}
if changed {
save()
} }
} }
} }

View File

@ -11,7 +11,6 @@ import UIKit
import Sentry import Sentry
#endif #endif
@MainActor
protocol TuskerSceneDelegate: UISceneDelegate { protocol TuskerSceneDelegate: UISceneDelegate {
var window: UIWindow? { get } var window: UIWindow? { get }
var rootViewController: TuskerRootViewController? { get } var rootViewController: TuskerRootViewController? { get }

View File

@ -9,7 +9,6 @@
import UIKit import UIKit
import PencilKit import PencilKit
@MainActor
protocol ComposeDrawingViewControllerDelegate: AnyObject { protocol ComposeDrawingViewControllerDelegate: AnyObject {
func composeDrawingViewControllerClose(_ drawingController: ComposeDrawingViewController) func composeDrawingViewControllerClose(_ drawingController: ComposeDrawingViewController)
func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing) func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing)

View File

@ -18,7 +18,6 @@ import CoreData
import Duckable import Duckable
#endif #endif
@MainActor
protocol ComposeHostingControllerDelegate: AnyObject { protocol ComposeHostingControllerDelegate: AnyObject {
func dismissCompose(mode: DismissMode) -> Bool func dismissCompose(mode: DismissMode) -> Bool
} }

View File

@ -296,7 +296,7 @@ extension ConversationCollectionViewController {
case mainStatus case mainStatus
case childThread(firstStatusID: String) case childThread(firstStatusID: String)
} }
enum Item: Hashable, Sendable { enum Item: Hashable {
case status(id: String, node: ConversationNode, state: CollapseState, prevLink: Bool, nextLink: Bool) case status(id: String, node: ConversationNode, state: CollapseState, prevLink: Bool, nextLink: Bool)
case expandThread(childThreads: [ConversationNode], inline: Bool) case expandThread(childThreads: [ConversationNode], inline: Bool)
case loadingIndicator case loadingIndicator
@ -306,7 +306,7 @@ extension ConversationCollectionViewController {
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, node: _, state: _, prevLink: aPrev, nextLink: aNext), .status(id: b, node: _, state: _, prevLink: bPrev, nextLink: bNext)):
return a == b && aPrev == bPrev && aNext == bNext return a == b && aPrev == bPrev && aNext == bNext
case let (.expandThread(childThreads: a, inline: aInline), .expandThread(childThreads: b, inline: bInline)): case let (.expandThread(childThreads: a, inline: aInline), .expandThread(childThreads: b, inline: bInline)):
return a.count == b.count && zip(a, b).allSatisfy { $0.statusID == $1.statusID } && aInline == bInline return a.count == b.count && zip(a, b).allSatisfy { $0.status.id == $1.status.id } && aInline == bInline
case (.loadingIndicator, .loadingIndicator): case (.loadingIndicator, .loadingIndicator):
return true return true
default: default:
@ -324,7 +324,7 @@ extension ConversationCollectionViewController {
case .expandThread(childThreads: let childThreads, inline: let inline): case .expandThread(childThreads: let childThreads, inline: let inline):
hasher.combine(1) hasher.combine(1)
for thread in childThreads { for thread in childThreads {
hasher.combine(thread.statusID) hasher.combine(thread.status.id)
} }
hasher.combine(inline) hasher.combine(inline)
case .loadingIndicator: case .loadingIndicator:

View File

@ -9,20 +9,15 @@
import Foundation import Foundation
import Pachyderm import Pachyderm
@MainActor
class ConversationNode { class ConversationNode {
let statusID: String
let status: StatusMO let status: StatusMO
var children: [ConversationNode] var children: [ConversationNode]
init(status: StatusMO) { init(status: StatusMO) {
self.statusID = status.id
self.status = status self.status = status
self.children = [] self.children = []
} }
} }
@MainActor
struct ConversationTree { struct ConversationTree {
let ancestors: [ConversationNode] let ancestors: [ConversationNode]
let mainStatus: ConversationNode let mainStatus: ConversationNode

View File

@ -194,7 +194,7 @@ class ConversationViewController: UIViewController {
if let cached = mastodonController.persistentContainer.status(for: mainStatusID) { if let cached = mastodonController.persistentContainer.status(for: mainStatusID) {
// if we have a cached copy, display it immediately but still try to refresh it // if we have a cached copy, display it immediately but still try to refresh it
Task { Task {
_ = await doLoadMainStatus() await doLoadMainStatus()
} }
mainStatusLoaded(cached) mainStatusLoaded(cached)
} else { } else {
@ -216,7 +216,7 @@ class ConversationViewController: UIViewController {
state = .loading(indicator) state = .loading(indicator)
let effectiveURL: String let effectiveURL: String
final class RedirectBlocker: NSObject, URLSessionTaskDelegate, Sendable { class RedirectBlocker: NSObject, URLSessionTaskDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil) completionHandler(nil)
} }

View File

@ -166,8 +166,7 @@ class IssueReporterViewController: UIViewController {
} }
extension IssueReporterViewController: MFMailComposeViewControllerDelegate { extension IssueReporterViewController: MFMailComposeViewControllerDelegate {
nonisolated func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) { func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
MainActor.runUnsafely {
controller.dismiss(animated: true) { controller.dismiss(animated: true) {
if result == .cancelled { if result == .cancelled {
// don't dismiss ourself, to allowe the user to send the report a different way // don't dismiss ourself, to allowe the user to send the report a different way
@ -178,4 +177,3 @@ extension IssueReporterViewController: MFMailComposeViewControllerDelegate {
} }
} }
} }
}

View File

@ -14,7 +14,7 @@ import WebURLFoundationExtras
class ExploreViewController: UIViewController, UICollectionViewDelegate, CollectionViewController { class ExploreViewController: UIViewController, UICollectionViewDelegate, CollectionViewController {
private let mastodonController: MastodonController weak var mastodonController: MastodonController!
var collectionView: UICollectionView! var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!

View File

@ -20,11 +20,8 @@ class FeaturedProfileCollectionViewCell: UICollectionViewCell {
var account: Account? var account: Account?
private var accountImagesTask: Task<Void, Never>? private var avatarRequest: ImageCache.Request?
private var headerRequest: ImageCache.Request?
deinit {
accountImagesTask?.cancel()
}
override func awakeFromNib() { override func awakeFromNib() {
super.awakeFromNib() super.awakeFromNib()
@ -65,35 +62,37 @@ class FeaturedProfileCollectionViewCell: UICollectionViewCell {
noteTextView.setEmojis(account.emojis, identifier: account.id) noteTextView.setEmojis(account.emojis, identifier: account.id)
avatarImageView.image = nil avatarImageView.image = nil
headerImageView.image = nil if let avatar = account.avatar {
avatarRequest = ImageCache.avatars.get(avatar) { [weak self] (_, image) in
accountImagesTask?.cancel() defer {
accountImagesTask = Task { self?.avatarRequest = nil
await updateImages(account: account)
} }
} guard let self = self,
let image = image,
private nonisolated func updateImages(account: Account) async { self.account?.id == account.id else {
await withTaskGroup(of: Void.self) { group in
group.addTask {
guard let avatar = account.avatar,
let image = await ImageCache.avatars.get(avatar).1 else {
return return
} }
await MainActor.run { DispatchQueue.main.async {
self.avatarImageView.image = image self.avatarImageView.image = image
} }
} }
group.addTask { }
guard let header = account.header,
let image = await ImageCache.headers.get(header).1 else { headerImageView.image = nil
if let header = account.header {
headerRequest = ImageCache.headers.get(header) { [weak self] (_, image) in
defer {
self?.headerRequest = nil
}
guard let self = self,
let image = image,
self.account?.id == account.id else {
return return
} }
await MainActor.run { DispatchQueue.main.async {
self.headerImageView.image = image self.headerImageView.image = image
} }
} }
await group.waitForAll()
} }
} }

View File

@ -11,7 +11,7 @@ import Pachyderm
class ProfileDirectoryViewController: UIViewController { class ProfileDirectoryViewController: UIViewController {
private let mastodonController: MastodonController weak var mastodonController: MastodonController!
private var collectionView: UICollectionView! private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!

View File

@ -11,7 +11,7 @@ import Pachyderm
class SuggestedProfilesViewController: UIViewController, CollectionViewController { class SuggestedProfilesViewController: UIViewController, CollectionViewController {
private let mastodonController: MastodonController weak var mastodonController: MastodonController!
var collectionView: UICollectionView! var collectionView: UICollectionView!
private var layout: MultiColumnCollectionViewLayout! private var layout: MultiColumnCollectionViewLayout!
@ -95,9 +95,7 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.accounts]) snapshot.appendSections([.accounts])
await MainActor.run { await dataSource.apply(snapshot)
dataSource.apply(snapshot)
}
do { do {
let request = Client.getSuggestions(limit: 80) let request = Client.getSuggestions(limit: 80)
@ -110,9 +108,7 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.accounts]) snapshot.appendSections([.accounts])
snapshot.appendItems(suggestions.map { .account($0.account.id, $0.source) }) snapshot.appendItems(suggestions.map { .account($0.account.id, $0.source) })
await MainActor.run { await dataSource.apply(snapshot)
dataSource.apply(snapshot)
}
state = .loaded state = .loaded
} catch { } catch {

View File

@ -13,7 +13,7 @@ import Combine
class TrendingHashtagsViewController: UIViewController { class TrendingHashtagsViewController: UIViewController {
private let mastodonController: MastodonController weak var mastodonController: MastodonController!
private var collectionView: UICollectionView! private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
@ -107,9 +107,7 @@ class TrendingHashtagsViewController: UIViewController {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.trendingTags]) snapshot.appendSections([.trendingTags])
snapshot.appendItems([.loadingIndicator]) snapshot.appendItems([.loadingIndicator])
await MainActor.run { await dataSource.apply(snapshot)
dataSource.apply(snapshot)
}
do { do {
let request = self.request(offset: nil) let request = self.request(offset: nil)
@ -117,14 +115,10 @@ class TrendingHashtagsViewController: UIViewController {
snapshot.deleteItems([.loadingIndicator]) snapshot.deleteItems([.loadingIndicator])
snapshot.appendItems(hashtags.map { .tag($0) }) snapshot.appendItems(hashtags.map { .tag($0) })
state = .loaded state = .loaded
await MainActor.run { await dataSource.apply(snapshot)
dataSource.apply(snapshot)
}
} catch { } catch {
snapshot.deleteItems([.loadingIndicator]) snapshot.deleteItems([.loadingIndicator])
await MainActor.run { await dataSource.apply(snapshot)
dataSource.apply(snapshot)
}
state = .unloaded state = .unloaded
let config = ToastConfiguration(from: error, with: "Error Loading Trending Tags", in: self) { [weak self] toast in let config = ToastConfiguration(from: error, with: "Error Loading Trending Tags", in: self) { [weak self] toast in
@ -146,9 +140,7 @@ class TrendingHashtagsViewController: UIViewController {
var snapshot = origSnapshot var snapshot = origSnapshot
if Preferences.shared.disableInfiniteScrolling { if Preferences.shared.disableInfiniteScrolling {
snapshot.appendItems([.confirmLoadMore(false)]) snapshot.appendItems([.confirmLoadMore(false)])
await MainActor.run { await dataSource.apply(snapshot)
dataSource.apply(snapshot)
}
for await _ in confirmLoadMore.values { for await _ in confirmLoadMore.values {
break break
@ -156,14 +148,10 @@ class TrendingHashtagsViewController: UIViewController {
snapshot.deleteItems([.confirmLoadMore(false)]) snapshot.deleteItems([.confirmLoadMore(false)])
snapshot.appendItems([.confirmLoadMore(true)]) snapshot.appendItems([.confirmLoadMore(true)])
await MainActor.run { await dataSource.apply(snapshot, animatingDifferences: false)
dataSource.apply(snapshot, animatingDifferences: false)
}
} else { } else {
snapshot.appendItems([.loadingIndicator]) snapshot.appendItems([.loadingIndicator])
await MainActor.run { await dataSource.apply(snapshot)
dataSource.apply(snapshot)
}
} }
do { do {
@ -171,13 +159,9 @@ class TrendingHashtagsViewController: UIViewController {
let (hashtags, _) = try await mastodonController.run(request) let (hashtags, _) = try await mastodonController.run(request)
var snapshot = origSnapshot var snapshot = origSnapshot
snapshot.appendItems(hashtags.map { .tag($0) }) snapshot.appendItems(hashtags.map { .tag($0) })
await MainActor.run { await dataSource.apply(snapshot)
dataSource.apply(snapshot)
}
} catch { } catch {
await MainActor.run { await dataSource.apply(origSnapshot)
dataSource.apply(origSnapshot)
}
let config = ToastConfiguration(from: error, with: "Error Loading More Tags", in: self) { [weak self] toast in let config = ToastConfiguration(from: error, with: "Error Loading More Tags", in: self) { [weak self] toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)

View File

@ -14,7 +14,7 @@ import Combine
class TrendingLinksViewController: UIViewController, CollectionViewController { class TrendingLinksViewController: UIViewController, CollectionViewController {
private let mastodonController: MastodonController weak var mastodonController: MastodonController!
var collectionView: UICollectionView! var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
@ -128,9 +128,7 @@ class TrendingLinksViewController: UIViewController, CollectionViewController {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.loadingIndicator]) snapshot.appendSections([.loadingIndicator])
snapshot.appendItems([.loadingIndicator]) snapshot.appendItems([.loadingIndicator])
await MainActor.run { await dataSource.apply(snapshot)
dataSource.apply(snapshot)
}
do { do {
let request = Client.getTrendingLinks() let request = Client.getTrendingLinks()
@ -139,13 +137,9 @@ class TrendingLinksViewController: UIViewController, CollectionViewController {
snapshot.appendSections([.links]) snapshot.appendSections([.links])
snapshot.appendItems(links.map { .link($0) }) snapshot.appendItems(links.map { .link($0) })
state = .loaded state = .loaded
await MainActor.run { await dataSource.apply(snapshot)
dataSource.apply(snapshot)
}
} catch { } catch {
await MainActor.run { await dataSource.apply(NSDiffableDataSourceSnapshot())
dataSource.apply(NSDiffableDataSourceSnapshot())
}
state = .unloaded state = .unloaded
let config = ToastConfiguration(from: error, with: "Error Loading Trending Links", in: self) { [weak self] toast in let config = ToastConfiguration(from: error, with: "Error Loading Trending Links", in: self) { [weak self] toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
@ -167,9 +161,7 @@ class TrendingLinksViewController: UIViewController, CollectionViewController {
if Preferences.shared.disableInfiniteScrolling { if Preferences.shared.disableInfiniteScrolling {
snapshot.appendSections([.loadingIndicator]) snapshot.appendSections([.loadingIndicator])
snapshot.appendItems([.confirmLoadMore(false)], toSection: .loadingIndicator) snapshot.appendItems([.confirmLoadMore(false)], toSection: .loadingIndicator)
await MainActor.run { await dataSource.apply(snapshot)
dataSource.apply(snapshot)
}
for await _ in confirmLoadMore.values { for await _ in confirmLoadMore.values {
break break
@ -177,15 +169,11 @@ class TrendingLinksViewController: UIViewController, CollectionViewController {
snapshot.deleteItems([.confirmLoadMore(false)]) snapshot.deleteItems([.confirmLoadMore(false)])
snapshot.appendItems([.confirmLoadMore(true)], toSection: .loadingIndicator) snapshot.appendItems([.confirmLoadMore(true)], toSection: .loadingIndicator)
await MainActor.run { await dataSource.apply(snapshot, animatingDifferences: false)
dataSource.apply(snapshot, animatingDifferences: false)
}
} else { } else {
snapshot.appendSections([.loadingIndicator]) snapshot.appendSections([.loadingIndicator])
snapshot.appendItems([.loadingIndicator], toSection: .loadingIndicator) snapshot.appendItems([.loadingIndicator], toSection: .loadingIndicator)
await MainActor.run { await dataSource.apply(snapshot)
dataSource.apply(snapshot)
}
} }
do { do {
@ -193,13 +181,9 @@ class TrendingLinksViewController: UIViewController, CollectionViewController {
let (links, _) = try await mastodonController.run(request) let (links, _) = try await mastodonController.run(request)
var snapshot = origSnapshot var snapshot = origSnapshot
snapshot.appendItems(links.map { .link($0) }, toSection: .links) snapshot.appendItems(links.map { .link($0) }, toSection: .links)
await MainActor.run { await dataSource.apply(snapshot)
dataSource.apply(snapshot)
}
} catch { } catch {
await MainActor.run { await dataSource.apply(origSnapshot)
dataSource.apply(origSnapshot)
}
let config = ToastConfiguration(from: error, with: "Erorr Loading More Links", in: self) { [weak self] toast in let config = ToastConfiguration(from: error, with: "Erorr Loading More Links", in: self) { [weak self] toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
await self?.loadOlder() await self?.loadOlder()

View File

@ -11,7 +11,7 @@ import Pachyderm
class TrendingStatusesViewController: UIViewController, CollectionViewController { class TrendingStatusesViewController: UIViewController, CollectionViewController {
private let mastodonController: MastodonController weak var mastodonController: MastodonController!
let filterer: Filterer let filterer: Filterer
var collectionView: UICollectionView! { var collectionView: UICollectionView! {
@ -126,9 +126,7 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0 statuses = try await mastodonController.run(Client.getTrendingStatuses()).0
} catch { } catch {
let snapshot = NSDiffableDataSourceSnapshot<Section, Item>() let snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
await MainActor.run { await dataSource.apply(snapshot)
dataSource.apply(snapshot)
}
let config = ToastConfiguration(from: error, with: "Loading Trending Posts", in: self) { toast in let config = ToastConfiguration(from: error, with: "Loading Trending Posts", in: self) { toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
await self.loadTrendingStatuses() await self.loadTrendingStatuses()
@ -140,9 +138,7 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses]) snapshot.appendSections([.statuses])
snapshot.appendItems(statuses.map { .status(id: $0.id, collapseState: .unknown, filterState: .unknown) }) snapshot.appendItems(statuses.map { .status(id: $0.id, collapseState: .unknown, filterState: .unknown) })
await MainActor.run { await dataSource.apply(snapshot)
dataSource.apply(snapshot)
}
} }
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) {

View File

@ -238,7 +238,7 @@ class TrendsViewController: UIViewController, CollectionViewController {
} }
isShowingTrends = shouldShowTrends isShowingTrends = shouldShowTrends
guard shouldShowTrends else { guard shouldShowTrends else {
await apply(snapshot: NSDiffableDataSourceSnapshot()) await dataSource.apply(NSDiffableDataSourceSnapshot())
return return
} }
@ -355,9 +355,9 @@ class TrendsViewController: UIViewController, CollectionViewController {
} }
private func apply(snapshot: NSDiffableDataSourceSnapshot<Section, Item>, animatingDifferences: Bool = true) async { private func apply(snapshot: NSDiffableDataSourceSnapshot<Section, Item>, animatingDifferences: Bool = true) async {
await MainActor.run { await Task { @MainActor in
self.dataSource.apply(snapshot, animatingDifferences: animatingDifferences) self.dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
} }.value
} }
@MainActor @MainActor

View File

@ -9,7 +9,6 @@
import UIKit import UIKit
import UserAccounts import UserAccounts
@MainActor
protocol FastAccountSwitcherViewControllerDelegate: AnyObject { protocol FastAccountSwitcherViewControllerDelegate: AnyObject {
func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController) func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController)
/// - Parameter point: In the coordinate space of the view to which the pan gesture recognizer is attached. /// - Parameter point: In the coordinate space of the view to which the pan gesture recognizer is attached.

View File

@ -49,7 +49,7 @@ class FastSwitchingAccountView: UIView {
private let instanceLabel = UILabel() private let instanceLabel = UILabel()
private let avatarImageView = UIImageView() private let avatarImageView = UIImageView()
private var avatarTask: Task<Void, Never>? private var avatarRequest: ImageCache.Request?
init(account: UserAccountInfo, orientation: FastAccountSwitcherViewController.ItemOrientation) { init(account: UserAccountInfo, orientation: FastAccountSwitcherViewController.ItemOrientation) {
self.orientation = orientation self.orientation = orientation
@ -69,10 +69,6 @@ class FastSwitchingAccountView: UIView {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
deinit {
avatarTask?.cancel()
}
private func commonInit() { private func commonInit() {
usernameLabel.textColor = .white usernameLabel.textColor = .white
usernameLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .headline), size: 0) usernameLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .headline), size: 0)
@ -137,14 +133,17 @@ class FastSwitchingAccountView: UIView {
instanceLabel.text = account.instanceURL.host! instanceLabel.text = account.instanceURL.host!
} }
let controller = MastodonController.getForAccount(account) let controller = MastodonController.getForAccount(account)
avatarTask = Task { controller.getOwnAccount { [weak self] (result) in
guard let account = try? await controller.getOwnAccount(), guard let self = self,
let avatar = account.avatar, case let .success(account) = result,
let image = await ImageCache.avatars.get(avatar).1 else { let avatar = account.avatar else { return }
return self.avatarRequest = ImageCache.avatars.get(avatar) { [weak self] (_, image) in
} guard let self = self, let image = image else { return }
DispatchQueue.main.async {
self.avatarImageView.image = image self.avatarImageView.image = image
} }
}
}
accessibilityLabel = "\(account.username!)@\(instanceLabel.text!)" accessibilityLabel = "\(account.username!)@\(instanceLabel.text!)"
} }

View File

@ -57,14 +57,12 @@ class GalleryFallbackViewController: QLPreviewController {
} }
extension GalleryFallbackViewController: QLPreviewControllerDataSource { extension GalleryFallbackViewController: QLPreviewControllerDataSource {
nonisolated func numberOfPreviewItems(in controller: QLPreviewController) -> Int { func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
return 1 return 1
} }
nonisolated func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
return MainActor.runUnsafely { return previewItem
previewItem
}
} }
} }

View File

@ -12,7 +12,6 @@ import Pachyderm
@preconcurrency import VisionKit @preconcurrency import VisionKit
import TuskerComponents import TuskerComponents
@MainActor
protocol LargeImageContentView: UIView { protocol LargeImageContentView: UIView {
var animationImage: UIImage? { get } var animationImage: UIImage? { get }
var activityItemsForSharing: [Any] { get } var activityItemsForSharing: [Any] { get }

View File

@ -144,9 +144,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
contentViewTopConstraint, contentViewTopConstraint,
]) ])
contentViewSizeObservation = (contentView as UIView).observe(\.bounds, changeHandler: { [unowned self] _, _ in contentViewSizeObservation = (contentView as UIView).observe(\.bounds, changeHandler: { [unowned self] _, _ in
MainActor.runUnsafely {
self.centerImage() self.centerImage()
}
}) })
} }

View File

@ -105,8 +105,8 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
embedChild(loadingVC!) embedChild(loadingVC!)
imageRequest = cache.get(url, loadOriginal: true) { [weak self] (data, image) in imageRequest = cache.get(url, loadOriginal: true) { [weak self] (data, image) in
guard let self = self, let image = image else { return } guard let self = self, let image = image else { return }
DispatchQueue.main.async {
self.imageRequest = nil self.imageRequest = nil
DispatchQueue.main.async {
self.loadingVC?.removeViewAndController() self.loadingVC?.removeViewAndController()
self.createLargeImage(data: data, image: image, url: self.url) self.createLargeImage(data: data, image: image, url: self.url)
} }

View File

@ -9,7 +9,6 @@
import UIKit import UIKit
import TuskerComponents import TuskerComponents
@MainActor
protocol LargeImageAnimatableViewController: UIViewController { protocol LargeImageAnimatableViewController: UIViewController {
var animationSourceView: UIImageView? { get } var animationSourceView: UIImageView? { get }
var largeImageController: LargeImageViewController? { get } var largeImageController: LargeImageViewController? { get }

View File

@ -195,9 +195,7 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.accounts]) snapshot.appendSections([.accounts])
snapshot.appendItems([.loadingIndicator]) snapshot.appendItems([.loadingIndicator])
await MainActor.run { await dataSource.apply(snapshot)
dataSource.apply(snapshot)
}
do { do {
let (accounts, pagination) = try await results let (accounts, pagination) = try await results
@ -212,9 +210,7 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.accounts]) snapshot.appendSections([.accounts])
snapshot.appendItems(accounts.map { .account(id: $0.id) }) snapshot.appendItems(accounts.map { .account(id: $0.id) })
await MainActor.run { await dataSource.apply(snapshot)
dataSource.apply(snapshot)
}
state = .loaded state = .loaded
} catch { } catch {
@ -225,9 +221,7 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
self.showToast(configuration: config, animated: true) self.showToast(configuration: config, animated: true)
state = .unloaded state = .unloaded
await MainActor.run { await dataSource.apply(.init())
dataSource.apply(.init())
}
} }
} }
@ -242,9 +236,7 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
let origSnapshot = dataSource.snapshot() let origSnapshot = dataSource.snapshot()
var snapshot = origSnapshot var snapshot = origSnapshot
snapshot.appendItems([.loadingIndicator]) snapshot.appendItems([.loadingIndicator])
await MainActor.run { await dataSource.apply(snapshot)
dataSource.apply(snapshot)
}
do { do {
let (accounts, pagination) = try await results let (accounts, pagination) = try await results
@ -258,9 +250,7 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
var snapshot = origSnapshot var snapshot = origSnapshot
snapshot.appendItems(accounts.map { .account(id: $0.id) }) snapshot.appendItems(accounts.map { .account(id: $0.id) })
await MainActor.run { await dataSource.apply(snapshot)
dataSource.apply(snapshot)
}
state = .loaded state = .loaded
} catch { } catch {
@ -271,9 +261,7 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
self.showToast(configuration: config, animated: true) self.showToast(configuration: config, animated: true)
state = .loaded state = .loaded
await MainActor.run { await dataSource.apply(origSnapshot)
dataSource.apply(origSnapshot)
}
} }
} }

View File

@ -13,7 +13,6 @@ import ScreenCorners
import UserAccounts import UserAccounts
import ComposeUI import ComposeUI
@MainActor
protocol AccountSwitchableViewController: TuskerRootViewController { protocol AccountSwitchableViewController: TuskerRootViewController {
var isFastAccountSwitcherActive: Bool { get } var isFastAccountSwitcherActive: Bool { get }
} }

View File

@ -10,7 +10,6 @@ import UIKit
import Pachyderm import Pachyderm
import Combine import Combine
@MainActor
protocol MainSidebarViewControllerDelegate: AnyObject { protocol MainSidebarViewControllerDelegate: AnyObject {
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController) func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item)

View File

@ -11,7 +11,7 @@ import Combine
class MainSplitViewController: UISplitViewController { class MainSplitViewController: UISplitViewController {
private let mastodonController: MastodonController weak var mastodonController: MastodonController!
private var sidebar: MainSidebarViewController! private var sidebar: MainSidebarViewController!
private var fastAccountSwitcher: FastAccountSwitcherViewController? private var fastAccountSwitcher: FastAccountSwitcherViewController?
@ -481,7 +481,6 @@ extension MainSplitViewController: MainSidebarViewControllerDelegate {
} }
fileprivate extension MainSidebarViewController.Item { fileprivate extension MainSidebarViewController.Item {
@MainActor
func createRootViewController(_ mastodonController: MastodonController) -> UIViewController? { func createRootViewController(_ mastodonController: MastodonController) -> UIViewController? {
switch self { switch self {
case let .tab(tab): case let .tab(tab):

View File

@ -11,7 +11,7 @@ import ComposeUI
class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
private let mastodonController: MastodonController weak var mastodonController: MastodonController!
private var composePlaceholder: UIViewController! private var composePlaceholder: UIViewController!
@ -207,7 +207,6 @@ extension MainTabBarViewController {
case explore case explore
case myProfile case myProfile
@MainActor
func createViewController(_ mastodonController: MastodonController) -> UIViewController { func createViewController(_ mastodonController: MastodonController) -> UIViewController {
switch self { switch self {
case .timelines: case .timelines:

View File

@ -83,7 +83,6 @@ enum TuskerRoute {
// case myProfile // case myProfile
//} //}
// //
@MainActor
protocol NavigationControllerProtocol: UIViewController { protocol NavigationControllerProtocol: UIViewController {
var viewControllers: [UIViewController] { get set } var viewControllers: [UIViewController] { get set }
var topViewController: UIViewController? { get } var topViewController: UIViewController? { get }

View File

@ -15,7 +15,7 @@ import Sentry
class NotificationsCollectionViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController { class NotificationsCollectionViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController {
private let mastodonController: MastodonController weak var mastodonController: MastodonController!
private let filterer: Filterer private let filterer: Filterer
private let allowedTypes: [Pachyderm.Notification.Kind] private let allowedTypes: [Pachyderm.Notification.Kind]
@ -273,11 +273,8 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
} }
} }
private nonisolated func dismissNotificationsInGroup(at indexPath: IndexPath) async { private func dismissNotificationsInGroup(at indexPath: IndexPath) async {
let item = await MainActor.run { guard case .group(let group, let collapseState, let filterState) = dataSource.itemIdentifier(for: indexPath) else {
dataSource.itemIdentifier(for: indexPath)
}
guard case .group(let group, let collapseState, let filterState) = item else {
return return
} }
let notifications = group.notifications let notifications = group.notifications
@ -298,9 +295,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
} }
}) })
} }
var snapshot = await MainActor.run { var snapshot = dataSource.snapshot()
dataSource.snapshot()
}
if dismissFailedIndices.isEmpty { if dismissFailedIndices.isEmpty {
snapshot.deleteItems([.group(group, collapseState, filterState)]) snapshot.deleteItems([.group(group, collapseState, filterState)])
} else if !dismissFailedIndices.isEmpty && dismissFailedIndices.count == notifications.count { } else if !dismissFailedIndices.isEmpty && dismissFailedIndices.count == notifications.count {

View File

@ -10,7 +10,6 @@ import UIKit
import Combine import Combine
import Pachyderm import Pachyderm
@MainActor
protocol InstanceSelectorTableViewControllerDelegate: AnyObject { protocol InstanceSelectorTableViewControllerDelegate: AnyObject {
func didSelectInstance(url: URL) func didSelectInstance(url: URL)
} }
@ -27,7 +26,7 @@ class InstanceSelectorTableViewController: UITableViewController {
weak var delegate: InstanceSelectorTableViewControllerDelegate? weak var delegate: InstanceSelectorTableViewControllerDelegate?
var dataSource: UITableViewDiffableDataSource<Section, Item>! var dataSource: DataSource!
var searchController: UISearchController! var searchController: UISearchController!
var recommendedInstances: [InstanceSelector.Instance] = [] var recommendedInstances: [InstanceSelector.Instance] = []
@ -73,7 +72,7 @@ class InstanceSelectorTableViewController: UITableViewController {
tableView.estimatedRowHeight = 120 tableView.estimatedRowHeight = 120
createActivityIndicatorHeader() createActivityIndicatorHeader()
dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in dataSource = DataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
switch item { switch item {
case let .selected(_, instance): case let .selected(_, instance):
let cell = tableView.dequeueReusableCell(withIdentifier: instanceCell, for: indexPath) as! InstanceTableViewCell let cell = tableView.dequeueReusableCell(withIdentifier: instanceCell, for: indexPath) as! InstanceTableViewCell
@ -311,7 +310,7 @@ extension InstanceSelectorTableViewController {
case selected case selected
case recommendedInstances case recommendedInstances
} }
enum Item: Equatable, Hashable, Sendable { enum Item: Equatable, Hashable {
case selected(URL, InstanceV1) case selected(URL, InstanceV1)
case recommended(InstanceSelector.Instance) case recommended(InstanceSelector.Instance)
@ -338,6 +337,9 @@ extension InstanceSelectorTableViewController {
} }
} }
} }
class DataSource: UITableViewDiffableDataSource<Section, Item> {
}
} }
extension InstanceSelectorTableViewController: UISearchResultsUpdating { extension InstanceSelectorTableViewController: UISearchResultsUpdating {

View File

@ -145,24 +145,27 @@ class OnboardingViewController: UINavigationController {
throw Error.gettingAccessToken(error) throw Error.gettingAccessToken(error)
} }
// construct a temporary Client to use to fetch the user's account // construct a temporary UserAccountInfo instance for the MastodonController to use to fetch its own account
let tempClient = Client(baseURL: instanceURL, accessToken: accessToken, session: .appDefault) let tempAccountInfo = UserAccountInfo(tempInstanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, accessToken: accessToken)
mastodonController.accountInfo = tempAccountInfo
updateStatus("Checking Credentials") updateStatus("Checking Credentials")
let ownAccount: Account let ownAccount: AccountMO
do { do {
ownAccount = try await retrying("Getting own account") { ownAccount = try await retrying("Getting own account") {
try await tempClient.run(Client.getSelfAccount()).0 try await mastodonController.getOwnAccount()
} }
} catch { } catch {
throw Error.gettingOwnAccount(error) throw Error.gettingOwnAccount(error)
} }
let accountInfo = UserAccountsManager.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: ownAccount.username, accessToken: accessToken) let accountInfo = UserAccountsManager.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: ownAccount.username, accessToken: accessToken)
mastodonController.accountInfo = accountInfo
self.onboardingDelegate?.didFinishOnboarding(account: accountInfo) self.onboardingDelegate?.didFinishOnboarding(account: accountInfo)
} }
private func retrying<T: Sendable>(_ label: StaticString, action: () async throws -> T) async throws -> T { private func retrying<T>(_ label: StaticString, action: () async throws -> T) async throws -> T {
for attempt in 0..<4 { for attempt in 0..<4 {
do { do {
return try await action() return try await action()

View File

@ -9,7 +9,6 @@
import SwiftUI import SwiftUI
import MessageUI import MessageUI
@MainActor
struct AboutView: View { struct AboutView: View {
@State private var logData: Data? @State private var logData: Data?
@State private var isGettingLogData = false @State private var isGettingLogData = false

View File

@ -32,21 +32,22 @@ struct LocalAccountAvatarView: View {
.resizable() .resizable()
.frame(width: 30, height: 30) .frame(width: 30, height: 30)
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 30) .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 30)
.task { .onAppear(perform: self.loadImage)
await self.loadImage()
}
} }
func loadImage() async { func loadImage() {
let controller = MastodonController.getForAccount(localAccountInfo) let controller = MastodonController.getForAccount(localAccountInfo)
guard let account = try? await controller.getOwnAccount(), controller.getOwnAccount { (result) in
let avatar = account.avatar, guard case let .success(account) = result,
let image = await ImageCache.avatars.get(avatar).1 else { let avatar = account.avatar else { return }
return _ = ImageCache.avatars.get(avatar) { (_, image) in
} DispatchQueue.main.async {
self.avatarImage = image self.avatarImage = image
} }
} }
}
}
}
//struct LocalAccountAvatarView_Previews: PreviewProvider { //struct LocalAccountAvatarView_Previews: PreviewProvider {
// static var previews: some View { // static var previews: some View {

View File

@ -103,7 +103,6 @@ private struct WideCapsule: Shape {
} }
} }
@MainActor
private protocol NavigationModePreview: UIView { private protocol NavigationModePreview: UIView {
init(startAnimation: PassthroughSubject<Void, Never>) init(startAnimation: PassthroughSubject<Void, Never>)
} }
@ -119,7 +118,6 @@ private struct NavigationModeRepresentable<UIViewType: NavigationModePreview>: U
} }
} }
@MainActor
private let timingParams = UISpringTimingParameters(mass: 1, stiffness: 70, damping: 16, initialVelocity: .zero) private let timingParams = UISpringTimingParameters(mass: 1, stiffness: 70, damping: 16, initialVelocity: .zero)
private final class StackNavigationPreview: UIView, NavigationModePreview { private final class StackNavigationPreview: UIView, NavigationModePreview {

View File

@ -18,14 +18,15 @@ class MyProfileViewController: ProfileViewController {
title = "My Profile" title = "My Profile"
tabBarItem.image = UIImage(systemName: "person.fill") tabBarItem.image = UIImage(systemName: "person.fill")
Task { mastodonController.getOwnAccount { (result) in
guard let account = try? await mastodonController.getOwnAccount() else { guard case let .success(account) = result else { return }
return
} DispatchQueue.main.async {
self.accountID = account.id self.accountID = account.id
self.setAvatarTabBarImage(account: account) self.setAvatarTabBarImage(account: account)
} }
} }
}
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")

View File

@ -12,7 +12,7 @@ import Combine
class ProfileViewController: UIViewController, StateRestorableViewController { class ProfileViewController: UIViewController, StateRestorableViewController {
let mastodonController: MastodonController weak var mastodonController: MastodonController!
// This property is optional because MyProfileViewController may not have the user's account ID // This property is optional because MyProfileViewController may not have the user's account ID
// when first constructed. It should never be set to nil. // when first constructed. It should never be set to nil.

View File

@ -8,7 +8,6 @@
import SwiftUI import SwiftUI
@MainActor
private var converter = HTMLConverter( private var converter = HTMLConverter(
font: .preferredFont(forTextStyle: .body), font: .preferredFont(forTextStyle: .body),
monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)), monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)),
@ -16,7 +15,6 @@ private var converter = HTMLConverter(
paragraphStyle: .default paragraphStyle: .default
) )
@MainActor
struct ReportStatusView: View { struct ReportStatusView: View {
let status: StatusMO let status: StatusMO
let mastodonController: MastodonController let mastodonController: MastodonController

View File

@ -15,7 +15,6 @@ fileprivate let accountCell = "accountCell"
fileprivate let statusCell = "statusCell" fileprivate let statusCell = "statusCell"
fileprivate let hashtagCell = "hashtagCell" fileprivate let hashtagCell = "hashtagCell"
@MainActor
protocol SearchResultsViewControllerDelegate: AnyObject { protocol SearchResultsViewControllerDelegate: AnyObject {
func selectedSearchResult(account accountID: String) func selectedSearchResult(account accountID: String)
func selectedSearchResult(hashtag: Hashtag) func selectedSearchResult(hashtag: Hashtag)
@ -30,7 +29,7 @@ extension SearchResultsViewControllerDelegate {
class SearchResultsViewController: UIViewController, CollectionViewController { class SearchResultsViewController: UIViewController, CollectionViewController {
let mastodonController: MastodonController weak var mastodonController: MastodonController!
weak var exploreNavigationController: UINavigationController? weak var exploreNavigationController: UINavigationController?
weak var delegate: SearchResultsViewControllerDelegate? weak var delegate: SearchResultsViewControllerDelegate?
@ -373,7 +372,7 @@ extension SearchResultsViewController {
} }
extension SearchResultsViewController { extension SearchResultsViewController {
enum Section: Hashable, Sendable { enum Section: Hashable {
case tokenSuggestions(SearchOperatorType) case tokenSuggestions(SearchOperatorType)
case loadingIndicator case loadingIndicator
case accounts case accounts
@ -395,7 +394,7 @@ extension SearchResultsViewController {
} }
} }
} }
enum Item: Hashable, Sendable { enum Item: Hashable {
case tokenSuggestion(String) case tokenSuggestion(String)
case loadingIndicator case loadingIndicator
case account(String) case account(String)

View File

@ -162,7 +162,7 @@ extension StatusEditHistoryViewController {
enum Section { enum Section {
case edits case edits
} }
enum Item: Hashable, Equatable, Sendable { enum Item: Hashable, Equatable {
case edit(StatusEdit, CollapseState, index: Int) case edit(StatusEdit, CollapseState, index: Int)
case loadingIndicator case loadingIndicator

View File

@ -9,7 +9,6 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
@MainActor
protocol InstanceTimelineViewControllerDelegate: AnyObject { protocol InstanceTimelineViewControllerDelegate: AnyObject {
func didSaveInstance(url: URL) func didSaveInstance(url: URL)
func didUnsaveInstance(url: URL) func didUnsaveInstance(url: URL)

View File

@ -13,7 +13,6 @@ import Combine
import Sentry import Sentry
#endif #endif
@MainActor
protocol TimelineViewControllerDelegate: AnyObject { protocol TimelineViewControllerDelegate: AnyObject {
func timelineViewController(_ timelineViewController: TimelineViewController, willShowJumpToPresentToastWith animator: UIViewPropertyAnimator?) func timelineViewController(_ timelineViewController: TimelineViewController, willShowJumpToPresentToastWith animator: UIViewPropertyAnimator?)
func timelineViewController(_ timelineViewController: TimelineViewController, willDismissJumpToPresentToastWith animator: UIViewPropertyAnimator?) func timelineViewController(_ timelineViewController: TimelineViewController, willDismissJumpToPresentToastWith animator: UIViewPropertyAnimator?)
@ -32,7 +31,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
weak var delegate: TimelineViewControllerDelegate? weak var delegate: TimelineViewControllerDelegate?
let timeline: Timeline let timeline: Timeline
let mastodonController: MastodonController weak var mastodonController: MastodonController!
private let filterer: Filterer private let filterer: Filterer
var persistsState = false var persistsState = false
@ -587,7 +586,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
snapshot.appendSections([.statuses]) snapshot.appendSections([.statuses])
let items = allStatuses.map { Item.status(id: $0.id, collapseState: .unknown, filterState: .unknown) } let items = allStatuses.map { Item.status(id: $0.id, collapseState: .unknown, filterState: .unknown) }
snapshot.appendItems(items) snapshot.appendItems(items)
await apply(snapshot, animatingDifferences: false) await dataSource.apply(snapshot, animatingDifferences: false)
collectionView.contentOffset = CGPoint(x: 0, y: -collectionView.adjustedContentInset.top) collectionView.contentOffset = CGPoint(x: 0, y: -collectionView.adjustedContentInset.top)
stateRestorationLogger.debug("TimelineViewController: restored from timeline marker with last read ID: \(home.lastReadID)") stateRestorationLogger.debug("TimelineViewController: restored from timeline marker with last read ID: \(home.lastReadID)")

View File

@ -8,7 +8,6 @@
import Foundation import Foundation
@MainActor
protocol BackgroundableViewController { protocol BackgroundableViewController {
func sceneDidEnterBackground() func sceneDidEnterBackground()
} }

View File

@ -8,7 +8,6 @@
import UIKit import UIKit
@MainActor
protocol CollectionViewController: UIViewController { protocol CollectionViewController: UIViewController {
var collectionView: UICollectionView! { get } var collectionView: UICollectionView! { get }
} }

View File

@ -18,14 +18,12 @@ protocol MenuActionProvider: AnyObject {
var toastableViewController: ToastableViewController? { get } var toastableViewController: ToastableViewController? { get }
} }
@MainActor
protocol MenuPreviewProvider: AnyObject { protocol MenuPreviewProvider: AnyObject {
typealias PreviewProviders = (content: UIContextMenuContentPreviewProvider, actions: () -> [UIMenuElement]) typealias PreviewProviders = (content: UIContextMenuContentPreviewProvider, actions: () -> [UIMenuElement])
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders?
} }
@MainActor
protocol CustomPreviewPresenting { protocol CustomPreviewPresenting {
func presentFromPreview(presenter: UIViewController) func presentFromPreview(presenter: UIViewController)
} }
@ -480,7 +478,7 @@ extension MenuActionProvider {
await fetchRelationship(accountID: accountID, mastodonController: mastodonController) await fetchRelationship(accountID: accountID, mastodonController: mastodonController)
} }
Task { @MainActor in Task { @MainActor in
if let relationship = await relationship.value?.value, if let relationship = await relationship.value,
let action = builder(relationship, mastodonController) { let action = builder(relationship, mastodonController) {
elementHandler([action]) elementHandler([action])
} else { } else {
@ -614,25 +612,20 @@ extension MenuActionProvider {
} }
private func fetchRelationship(accountID: String, mastodonController: MastodonController) async -> MainThreadBox<RelationshipMO>? { private func fetchRelationship(accountID: String, mastodonController: MastodonController) async -> RelationshipMO? {
let req = Client.getRelationships(accounts: [accountID]) let req = Client.getRelationships(accounts: [accountID])
guard let (relationships, _) = try? await mastodonController.run(req), guard let (relationships, _) = try? await mastodonController.run(req),
let r = relationships.first else { let r = relationships.first else {
return nil return nil
} }
return await withCheckedContinuation { continuation in return await withCheckedContinuation { continuation in
DispatchQueue.main.async {
mastodonController.persistentContainer.addOrUpdate(relationship: r, in: mastodonController.persistentContainer.viewContext) { mo in mastodonController.persistentContainer.addOrUpdate(relationship: r, in: mastodonController.persistentContainer.viewContext) { mo in
continuation.resume(returning: MainThreadBox(value: mo)) continuation.resume(returning: mo)
}
} }
} }
} }
struct MenuPreviewHelper { struct MenuPreviewHelper {
private init() {}
@MainActor
static func willPerformPreviewAction(animator: UIContextMenuInteractionCommitAnimating, presenter: UIViewController) { static func willPerformPreviewAction(animator: UIContextMenuInteractionCommitAnimating, presenter: UIViewController) {
if let viewController = animator.previewViewController { if let viewController = animator.previewViewController {
animator.preferredCommitStyle = .pop animator.preferredCommitStyle = .pop

View File

@ -8,7 +8,6 @@
import UIKit import UIKit
@MainActor
@objc protocol RefreshableViewController { @objc protocol RefreshableViewController {
func refresh() func refresh()

View File

@ -339,7 +339,6 @@ private class SplitSecondaryNavigationController: EnhancedNavigationViewControll
} }
@MainActor
protocol NestedResponderProvider { protocol NestedResponderProvider {
var innerResponder: UIResponder? { get } var innerResponder: UIResponder? { get }
} }

View File

@ -8,7 +8,6 @@
import UIKit import UIKit
@MainActor
protocol StateRestorableViewController: UIViewController { protocol StateRestorableViewController: UIViewController {
func stateRestorationActivity() -> NSUserActivity? func stateRestorationActivity() -> NSUserActivity?
} }

View File

@ -8,7 +8,6 @@
import UIKit import UIKit
@MainActor
protocol StatusBarTappableViewController: UIViewController { protocol StatusBarTappableViewController: UIViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult
} }

View File

@ -8,7 +8,6 @@
import UIKit import UIKit
@MainActor
protocol TabBarScrollableViewController: UIViewController { protocol TabBarScrollableViewController: UIViewController {
func tabBarScrollToTop() func tabBarScrollToTop()
} }

View File

@ -8,7 +8,6 @@
import UIKit import UIKit
@MainActor
@objc protocol TabbedPageViewController { @objc protocol TabbedPageViewController {
func selectNextPage() func selectNextPage()
func selectPrevPage() func selectPrevPage()

View File

@ -54,7 +54,6 @@ enum AppShortcutItem: String, CaseIterable {
} }
extension AppShortcutItem { extension AppShortcutItem {
@MainActor
static func createItems(for application: UIApplication) { static func createItems(for application: UIApplication) {
application.shortcutItems = allCases.map { application.shortcutItems = allCases.map {
return UIApplicationShortcutItem(type: $0.rawValue, localizedTitle: $0.title, localizedSubtitle: nil, icon: $0.icon, userInfo: nil) return UIApplicationShortcutItem(type: $0.rawValue, localizedTitle: $0.title, localizedSubtitle: nil, icon: $0.icon, userInfo: nil)

View File

@ -10,7 +10,6 @@ import Foundation
import OSLog import OSLog
import Combine import Combine
@MainActor
protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject { protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
associatedtype TimelineItem: Sendable associatedtype TimelineItem: Sendable
@ -218,7 +217,7 @@ class TimelineLikeController<Item: Sendable> {
} }
} }
enum State: Equatable, CustomDebugStringConvertible, Sendable { enum State: Equatable, CustomDebugStringConvertible {
case notLoadedInitial case notLoadedInitial
case idle case idle
case restoringInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool) case restoringInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
@ -361,7 +360,7 @@ class TimelineLikeController<Item: Sendable> {
} }
} }
final class LoadAttemptToken: Equatable, Sendable { class LoadAttemptToken: Equatable {
static func ==(lhs: LoadAttemptToken, rhs: LoadAttemptToken) -> Bool { static func ==(lhs: LoadAttemptToken, rhs: LoadAttemptToken) -> Bool {
return lhs === rhs return lhs === rhs
} }

View File

@ -191,7 +191,6 @@ enum PopoverSource {
case view(WeakHolder<UIView>) case view(WeakHolder<UIView>)
case barButtonItem(WeakHolder<UIBarButtonItem>) case barButtonItem(WeakHolder<UIBarButtonItem>)
@MainActor
func apply(to viewController: UIViewController) { func apply(to viewController: UIViewController) {
if let popoverPresentationController = viewController.popoverPresentationController { if let popoverPresentationController = viewController.popoverPresentationController {
switch self { switch self {

View File

@ -73,8 +73,8 @@ class LargeAccountDetailView: UIView {
if let avatar = account.avatar { if let avatar = account.avatar {
avatarRequest = ImageCache.avatars.get(avatar) { [weak self] (_, image) in avatarRequest = ImageCache.avatars.get(avatar) { [weak self] (_, image) in
guard let self = self, let image = image else { return } guard let self = self, let image = image else { return }
DispatchQueue.main.async {
self.avatarRequest = nil self.avatarRequest = nil
DispatchQueue.main.async {
self.avatarImageView.image = image self.avatarImageView.image = image
} }
} }

View File

@ -11,7 +11,6 @@ import Pachyderm
import AVFoundation import AVFoundation
import TuskerComponents import TuskerComponents
@MainActor
protocol AttachmentViewDelegate: AnyObject { protocol AttachmentViewDelegate: AnyObject {
func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? func attachmentViewGallery(startingAt index: Int) -> GalleryViewController?
func attachmentViewPresent(_ vc: UIViewController, animated: Bool) func attachmentViewPresent(_ vc: UIViewController, animated: Bool)
@ -30,7 +29,7 @@ class AttachmentView: GIFImageView {
var attachment: Attachment! var attachment: Attachment!
var index: Int! var index: Int!
private var loadAttachmentTask: Task<Void, Never>? private var attachmentRequest: ImageCache.Request?
private var source: Source? private var source: Source?
private var autoplayGifs: Bool { private var autoplayGifs: Bool {
@ -45,13 +44,11 @@ class AttachmentView: GIFImageView {
self.attachment = attachment self.attachment = attachment
self.index = index self.index = index
self.loadAttachmentTask = Task { loadAttachment()
await self.loadAttachment()
}
} }
deinit { deinit {
loadAttachmentTask?.cancel() attachmentRequest?.cancel()
} }
required init?(coder aDecoder: NSCoder) { required init?(coder aDecoder: NSCoder) {
@ -78,9 +75,7 @@ class AttachmentView: GIFImageView {
gifPlaybackModeChanged() gifPlaybackModeChanged()
if isGrayscale != Preferences.shared.grayscaleImages { if isGrayscale != Preferences.shared.grayscaleImages {
Task { self.displayImage()
await displayImage()
}
} }
if getBadges().isEmpty != Preferences.shared.showAttachmentBadges { if getBadges().isEmpty != Preferences.shared.showAttachmentBadges {
@ -111,34 +106,34 @@ class AttachmentView: GIFImageView {
} }
} }
private func loadAttachment() async { func loadAttachment() {
let blurHashTask: Task<Void, any Error>?
if let hash = attachment.blurHash { if let hash = attachment.blurHash {
blurHashTask = Task { AttachmentView.queue.async { [weak self] in
guard let self = self else { return }
guard var preview = UIImage(blurHash: hash, size: self.blurHashSize()) else { guard var preview = UIImage(blurHash: hash, size: self.blurHashSize()) else {
return return
} }
try Task.checkCancellation()
if Preferences.shared.grayscaleImages, if Preferences.shared.grayscaleImages,
let grayscale = ImageGrayscalifier.convert(url: nil, cgImage: preview.cgImage!) { let grayscale = ImageGrayscalifier.convert(url: nil, cgImage: preview.cgImage!) {
preview = grayscale preview = grayscale
} }
try Task.checkCancellation()
DispatchQueue.main.async { [weak self] in
guard let self = self, self.image == nil else { return }
self.image = preview self.image = preview
} }
} else { }
blurHashTask = nil
} }
createBadgesView(getBadges()) createBadgesView(getBadges())
switch attachment.kind { switch attachment.kind {
case .image: case .image:
await loadImage() loadImage()
case .video: case .video:
await loadVideo() loadVideo()
case .audio: case .audio:
loadAudio() loadAudio()
case .gifv: case .gifv:
@ -146,8 +141,6 @@ class AttachmentView: GIFImageView {
case .unknown: case .unknown:
createUnknownLabel() createUnknownLabel()
} }
blurHashTask?.cancel()
} }
private func getBadges() -> Badges { private func getBadges() -> Badges {
@ -188,27 +181,66 @@ class AttachmentView: GIFImageView {
} }
} }
private func loadImage() async { func loadImage() {
let (data, image) = await ImageCache.attachments.get(attachment.url) let attachmentURL = attachment.url
guard !Task.isCancelled else { return } attachmentRequest = ImageCache.attachments.get(attachmentURL) { [weak self] (data, image) in
guard let self = self,
self.attachment.url == attachmentURL else {
return
}
if attachment.url.pathExtension == "gif", DispatchQueue.main.async {
self.attachmentRequest = nil
if attachmentURL.pathExtension == "gif",
let data { let data {
source = .gifData(attachment.url, data, image) self.source = .gifData(attachmentURL, data, image)
if autoplayGifs { if self.autoplayGifs {
let controller = GIFController(gifData: data) let controller = GIFController(gifData: data)
controller.attach(to: self) controller.attach(to: self)
controller.startAnimating() controller.startAnimating()
} else { } else {
await displayImage() self.displayImage()
} }
} else if let image { } else if let image {
source = .image(attachment.url, image) self.source = .image(attachmentURL, image)
await displayImage() self.displayImage()
}
}
}
}
func loadVideo() {
if let previewURL = self.attachment.previewURL {
attachmentRequest = ImageCache.attachments.get(previewURL, completion: { [weak self] (_, image) in
guard let self, let image else { return }
DispatchQueue.main.async {
self.attachmentRequest = nil
self.source = .image(previewURL, image)
self.displayImage()
}
})
} else {
let attachmentURL = self.attachment.url
AttachmentView.queue.async { [weak self] in
let asset = AVURLAsset(url: attachmentURL)
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
#if os(visionOS)
#warning("Use async AVAssetImageGenerator.image(at:)")
#else
guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return }
UIImage(cgImage: image).prepareForDisplay { [weak self] image in
DispatchQueue.main.async { [weak self] in
guard let self, let image else { return }
self.source = .image(attachmentURL, image)
self.displayImage()
}
}
#endif
} }
} }
private func loadVideo() async {
let playImageView = UIImageView(image: UIImage(systemName: "play.circle.fill")) let playImageView = UIImageView(image: UIImage(systemName: "play.circle.fill"))
playImageView.translatesAutoresizingMaskIntoConstraints = false playImageView.translatesAutoresizingMaskIntoConstraints = false
addSubview(playImageView) addSubview(playImageView)
@ -218,40 +250,9 @@ class AttachmentView: GIFImageView {
playImageView.centerXAnchor.constraint(equalTo: centerXAnchor), playImageView.centerXAnchor.constraint(equalTo: centerXAnchor),
playImageView.centerYAnchor.constraint(equalTo: centerYAnchor), playImageView.centerYAnchor.constraint(equalTo: centerYAnchor),
]) ])
if let previewURL = attachment.previewURL {
guard let image = await ImageCache.attachments.get(previewURL).1,
!Task.isCancelled else {
return
} }
source = .image(previewURL, image) func loadAudio() {
await displayImage()
} else {
let asset = AVURLAsset(url: attachment.url)
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
let image: CGImage?
#if os(visionOS)
image = try? await generator.image(at: .zero).image
#else
if #available(iOS 16.0, *) {
image = try? await generator.image(at: .zero).image
} else {
image = try? generator.copyCGImage(at: .zero, actualTime: nil)
}
#endif
guard let image,
let prepared = await UIImage(cgImage: image).byPreparingForDisplay(),
!Task.isCancelled else {
return
}
source = .image(attachment.url, prepared)
await displayImage()
}
}
private func loadAudio() {
let label = UILabel() let label = UILabel()
label.text = "Audio Only" label.text = "Audio Only"
let playImageView = UIImageView(image: UIImage(systemName: "play.circle.fill")) let playImageView = UIImageView(image: UIImage(systemName: "play.circle.fill"))
@ -272,8 +273,23 @@ class AttachmentView: GIFImageView {
]) ])
} }
private func loadGifv() { func loadGifv() {
let asset = AVURLAsset(url: attachment.url) let attachmentURL = self.attachment.url
let asset = AVURLAsset(url: attachmentURL)
AttachmentView.queue.async {
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
#if os(visionOS)
#warning("Use async AVAssetImageGenerator.image(at:)")
#else
guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return }
DispatchQueue.main.async {
self.source = .cgImage(attachmentURL, image)
self.displayImage()
}
#endif
}
let gifvView = GifvAttachmentView(asset: asset, gravity: .resizeAspectFill) let gifvView = GifvAttachmentView(asset: asset, gravity: .resizeAspectFill)
self.gifvView = gifvView self.gifvView = gifvView
gifvView.translatesAutoresizingMaskIntoConstraints = false gifvView.translatesAutoresizingMaskIntoConstraints = false
@ -289,7 +305,7 @@ class AttachmentView: GIFImageView {
]) ])
} }
private func createUnknownLabel() { func createUnknownLabel() {
backgroundColor = .appSecondaryBackground backgroundColor = .appSecondaryBackground
let label = UILabel() let label = UILabel()
label.text = "Unknown Attachment Type" label.text = "Unknown Attachment Type"
@ -308,7 +324,8 @@ class AttachmentView: GIFImageView {
]) ])
} }
private func displayImage() async { @MainActor
private func displayImage() {
isGrayscale = Preferences.shared.grayscaleImages isGrayscale = Preferences.shared.grayscaleImages
switch source { switch source {
@ -317,7 +334,12 @@ class AttachmentView: GIFImageView {
case let .image(url, sourceImage): case let .image(url, sourceImage):
if isGrayscale { if isGrayscale {
self.image = await ImageGrayscalifier.convert(url: url, image: sourceImage) ImageGrayscalifier.queue.async { [weak self] in
let grayscale = ImageGrayscalifier.convert(url: url, image: sourceImage)
DispatchQueue.main.async { [weak self] in
self?.image = grayscale
}
}
} else { } else {
self.image = sourceImage self.image = sourceImage
} }
@ -325,16 +347,26 @@ class AttachmentView: GIFImageView {
case let .gifData(url, _, sourceImage): case let .gifData(url, _, sourceImage):
if isGrayscale, if isGrayscale,
let sourceImage { let sourceImage {
self.image = await ImageGrayscalifier.convert(url: url, image: sourceImage) ImageGrayscalifier.queue.async { [weak self] in
let grayscale = ImageGrayscalifier.convert(url: url, image: sourceImage)
DispatchQueue.main.async { [weak self] in
self?.image = grayscale
}
}
} else { } else {
self.image = sourceImage self.image = sourceImage
} }
case let .cgImage(url, cgImage): case let .cgImage(url, cgImage):
if isGrayscale { if isGrayscale {
self.image = await ImageGrayscalifier.convert(url: url, cgImage: cgImage) ImageGrayscalifier.queue.async { [weak self] in
let grayscale = ImageGrayscalifier.convert(url: url, cgImage: cgImage)
DispatchQueue.main.async { [weak self] in
self?.image = grayscale
}
}
} else { } else {
self.image = UIImage(cgImage: cgImage) image = UIImage(cgImage: cgImage)
} }
} }
} }

View File

@ -11,8 +11,12 @@ import Pachyderm
import WebURLFoundationExtras import WebURLFoundationExtras
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
#if os(visionOS)
private let imageScale: CGFloat = 2
#else
private let imageScale = UIScreen.main.scale
#endif
@MainActor
protocol BaseEmojiLabel: AnyObject { protocol BaseEmojiLabel: AnyObject {
var emojiIdentifier: AnyHashable? { get set } var emojiIdentifier: AnyHashable? { get set }
var emojiRequests: [ImageCache.Request] { get set } var emojiRequests: [ImageCache.Request] { get set }
@ -43,15 +47,10 @@ extension BaseEmojiLabel {
// Based on https://github.com/ReticentJohn/Amaroq/blob/7c5b7088eb9fd1611dcb0f47d43bf8df093e142c/DireFloof/InlineImageHelpers.m // Based on https://github.com/ReticentJohn/Amaroq/blob/7c5b7088eb9fd1611dcb0f47d43bf8df093e142c/DireFloof/InlineImageHelpers.m
let adjustedCapHeight = emojiFont.capHeight - 1 let adjustedCapHeight = emojiFont.capHeight - 1
#if os(visionOS)
let screenScale: CGFloat = 2
#else
let screenScale = UIScreen.main.scale
#endif
@Sendable
func emojiImageSize(_ image: UIImage) -> CGSize { func emojiImageSize(_ image: UIImage) -> CGSize {
var imageSizeMatchingFontSize = CGSize(width: image.size.width * (adjustedCapHeight / image.size.height), height: adjustedCapHeight) var imageSizeMatchingFontSize = CGSize(width: image.size.width * (adjustedCapHeight / image.size.height), height: adjustedCapHeight)
let scale = 1.4 * screenScale var scale: CGFloat = 1.4
scale *= imageScale
imageSizeMatchingFontSize = CGSize(width: imageSizeMatchingFontSize.width * scale, height: imageSizeMatchingFontSize.height * scale) imageSizeMatchingFontSize = CGSize(width: imageSizeMatchingFontSize.width * scale, height: imageSizeMatchingFontSize.height * scale)
return imageSizeMatchingFontSize return imageSizeMatchingFontSize
} }
@ -81,7 +80,7 @@ extension BaseEmojiLabel {
let cgImage = thumbnail.cgImage { let cgImage = thumbnail.cgImage {
// the thumbnail API takes a pixel size and returns an image with scale 1, but we want the actual screen scale, so convert // the thumbnail API takes a pixel size and returns an image with scale 1, but we want the actual screen scale, so convert
// see FB12187798 // see FB12187798
emojiImages[emoji.shortcode] = UIImage(cgImage: cgImage, scale: screenScale, orientation: .up) emojiImages[emoji.shortcode] = UIImage(cgImage: cgImage, scale: imageScale, orientation: .up)
} }
} else { } else {
// otherwise, perform the network request // otherwise, perform the network request
@ -94,7 +93,7 @@ extension BaseEmojiLabel {
} }
image.prepareThumbnail(of: emojiImageSize(image)) { thumbnail in image.prepareThumbnail(of: emojiImageSize(image)) { thumbnail in
guard let thumbnail = thumbnail?.cgImage, guard let thumbnail = thumbnail?.cgImage,
case let rescaled = UIImage(cgImage: thumbnail, scale: screenScale, orientation: .up), case let rescaled = UIImage(cgImage: thumbnail, scale: imageScale, orientation: .up),
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: URL(emoji.url)!, image: rescaled) else { let transformedImage = ImageGrayscalifier.convertIfNecessary(url: URL(emoji.url)!, image: rescaled) else {
group.leave() group.leave()
return return

View File

@ -90,7 +90,8 @@ class CachedImageView: UIImageView {
return return
} }
try Task.checkCancellation() try Task.checkCancellation()
guard let transformedImage = await ImageGrayscalifier.convertIfNecessary(url: url, image: image) else { // TODO: check that this isn't on the main thread
guard let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) else {
return return
} }
try Task.checkCancellation() try Task.checkCancellation()

View File

@ -93,9 +93,7 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
updateLinkUnderlineStyle() updateLinkUnderlineStyle()
} }
@MainActor private func updateLinkUnderlineStyle(preference: Bool = Preferences.shared.underlineTextLinks) {
private func updateLinkUnderlineStyle(preference: Bool? = nil) {
let preference = preference ?? Preferences.shared.underlineTextLinks
if UIAccessibility.buttonShapesEnabled || preference { if UIAccessibility.buttonShapesEnabled || preference {
linkTextAttributes[.underlineStyle] = NSUnderlineStyle.single.rawValue linkTextAttributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
} else { } else {

View File

@ -19,11 +19,8 @@ class InstanceTableViewCell: UITableViewCell {
var instance: InstanceV1? var instance: InstanceV1?
var selectorInstance: InstanceSelector.Instance? var selectorInstance: InstanceSelector.Instance?
private var thumbnailTask: Task<Void, Never>? var thumbnailURL: URL?
var thumbnailRequest: ImageCache.Request?
deinit {
thumbnailTask?.cancel()
}
override func awakeFromNib() { override func awakeFromNib() {
super.awakeFromNib() super.awakeFromNib()
@ -71,19 +68,20 @@ class InstanceTableViewCell: UITableViewCell {
private func updateThumbnail(url: URL) { private func updateThumbnail(url: URL) {
thumbnailImageView.image = nil thumbnailImageView.image = nil
thumbnailTask = Task { thumbnailURL = url
guard let image = await ImageCache.attachments.get(url).1, thumbnailRequest = ImageCache.attachments.get(url) { [weak self] (_, image) in
!Task.isCancelled else { guard let self = self, self.thumbnailURL == url, let image = image else { return }
return self.thumbnailRequest = nil
DispatchQueue.main.async {
self.thumbnailImageView.image = image
} }
thumbnailImageView.image = image
} }
} }
override func prepareForReuse() { override func prepareForReuse() {
super.prepareForReuse() super.prepareForReuse()
thumbnailTask?.cancel() thumbnailRequest?.cancel()
instance = nil instance = nil
selectorInstance = nil selectorInstance = nil
} }

View File

@ -24,7 +24,7 @@ class MultiSourceEmojiLabel: UILabel, BaseEmojiLabel {
var attributedStrings = pairs.map { NSAttributedString(string: $0.0) } var attributedStrings = pairs.map { NSAttributedString(string: $0.0) }
let recombine: @MainActor @Sendable () -> Void = { [weak self] in let recombine = { [weak self] in
if let self, if let self,
let combiner = self.combiner { let combiner = self.combiner {
self.attributedText = combiner(attributedStrings) self.attributedText = combiner(attributedStrings)

View File

@ -53,9 +53,7 @@ class ProfileFieldsView: UIView {
private func commonInit() { private func commonInit() {
boundsObservation = observe(\.bounds, changeHandler: { [unowned self] _, _ in boundsObservation = observe(\.bounds, changeHandler: { [unowned self] _, _ in
MainActor.runUnsafely {
self.setNeedsUpdateConstraints() self.setNeedsUpdateConstraints()
}
}) })
#if os(visionOS) #if os(visionOS)

View File

@ -9,7 +9,6 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
@MainActor
protocol ProfileHeaderViewDelegate: TuskerNavigationDelegate, MenuActionProvider { protocol ProfileHeaderViewDelegate: TuskerNavigationDelegate, MenuActionProvider {
func profileHeader(_ headerView: ProfileHeaderView, selectedPageChangedTo newPage: ProfileViewController.Page) func profileHeader(_ headerView: ProfileHeaderView, selectedPageChangedTo newPage: ProfileViewController.Page)
} }
@ -42,7 +41,8 @@ class ProfileHeaderView: UIView {
var accountID: String! var accountID: String!
private var imagesTask: Task<Void, Never>? private var avatarRequest: ImageCache.Request?
private var headerRequest: ImageCache.Request?
private var isGrayscale = false private var isGrayscale = false
private var followButtonMode = FollowButtonMode.follow { private var followButtonMode = FollowButtonMode.follow {
@ -55,7 +55,8 @@ class ProfileHeaderView: UIView {
} }
deinit { deinit {
imagesTask?.cancel() avatarRequest?.cancel()
headerRequest?.cancel()
} }
override func awakeFromNib() { override func awakeFromNib() {
@ -132,12 +133,7 @@ class ProfileHeaderView: UIView {
usernameLabel.text = "@\(account.acct)" usernameLabel.text = "@\(account.acct)"
lockImageView.isHidden = !account.locked lockImageView.isHidden = !account.locked
imagesTask?.cancel() updateImages(account: account)
let avatar = account.avatar
let header = account.header
imagesTask = Task {
await updateImages(avatar: avatar, header: header)
}
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForProfile(accountID: accountID, source: .view(moreButton), fetchRelationship: false) ?? []) moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForProfile(accountID: accountID, source: .view(moreButton), fetchRelationship: false) ?? [])
@ -289,41 +285,50 @@ class ProfileHeaderView: UIView {
displayNameLabel.updateForAccountDisplayName(account: account) displayNameLabel.updateForAccountDisplayName(account: account)
if isGrayscale != Preferences.shared.grayscaleImages { if isGrayscale != Preferences.shared.grayscaleImages {
isGrayscale = Preferences.shared.grayscaleImages updateImages(account: account)
imagesTask?.cancel()
let avatar = account.avatar
let header = account.header
imagesTask = Task {
await updateImages(avatar: avatar, header: header)
}
} }
} }
private nonisolated func updateImages(avatar: URL?, header: URL?) async { private func updateImages(account: AccountMO) {
await withTaskGroup(of: Void.self) { group in isGrayscale = Preferences.shared.grayscaleImages
group.addTask {
guard let avatar, let accountID = account.id
let image = await ImageCache.avatars.get(avatar, loadOriginal: true).1, if let avatarURL = account.avatar {
let transformedImage = await ImageGrayscalifier.convertIfNecessary(url: avatar, image: image), // always load original for avatars, because ImageCache.avatars stores them scaled-down in memory
!Task.isCancelled else { avatarRequest = ImageCache.avatars.get(avatarURL, loadOriginal: true) { [weak self] (_, image) in
guard let self = self,
let image = image,
self.accountID == accountID,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else {
DispatchQueue.main.async {
self?.avatarRequest = nil
}
return return
} }
await MainActor.run {
DispatchQueue.main.async {
self.avatarRequest = nil
self.avatarImageView.image = transformedImage self.avatarImageView.image = transformedImage
} }
} }
group.addTask { }
guard let header, if let header = account.header {
let image = await ImageCache.avatars.get(header, loadOriginal: true).1, headerRequest = ImageCache.headers.get(header) { [weak self] (_, image) in
let transformedImage = await ImageGrayscalifier.convertIfNecessary(url: header, image: image), guard let self = self,
!Task.isCancelled else { let image = image,
self.accountID == accountID,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: header, image: image) else {
DispatchQueue.main.async {
self?.headerRequest = nil
}
return return
} }
await MainActor.run {
DispatchQueue.main.async {
self.headerRequest = nil
self.headerImageView.image = transformedImage self.headerImageView.image = transformedImage
} }
} }
await group.waitForAll()
} }
} }

View File

@ -179,16 +179,13 @@ extension StatusContentContainer {
} }
private extension UIView { private extension UIView {
func observeIsHidden(_ f: @escaping @Sendable @MainActor () -> Void) -> NSKeyValueObservation { func observeIsHidden(_ f: @escaping () -> Void) -> NSKeyValueObservation {
self.observe(\.isHidden) { _, _ in self.observe(\.isHidden) { _, _ in
MainActor.runUnsafely {
f() f()
} }
} }
} }
}
@MainActor
protocol StatusContentView: UIView { protocol StatusContentView: UIView {
var statusContentFillsHorizontally: Bool { get } var statusContentFillsHorizontally: Bool { get }
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat func estimateHeight(effectiveWidth: CGFloat) -> CGFloat

View File

@ -20,8 +20,8 @@ struct ToastConfiguration {
var title: String var title: String
var subtitle: String? var subtitle: String?
var actionTitle: String? var actionTitle: String?
var action: (@MainActor (ToastView) -> Void)? var action: ((ToastView) -> Void)?
var longPressAction: (@MainActor (ToastView) -> Void)? var longPressAction: ((ToastView) -> Void)?
var edgeSpacing: CGFloat = 8 var edgeSpacing: CGFloat = 8
var edge: Edge = .automatic var edge: Edge = .automatic
var dismissOnScroll = true var dismissOnScroll = true
@ -42,7 +42,7 @@ struct ToastConfiguration {
} }
extension ToastConfiguration { extension ToastConfiguration {
init(from error: Error, with title: String, in viewController: TuskerNavigationDelegate, retryAction: (@MainActor (ToastView) -> Void)?) { init(from error: Error, with title: String, in viewController: TuskerNavigationDelegate, retryAction: ((ToastView) -> Void)?) {
self.init(title: title) self.init(title: title)
// localizedDescription is statically dispatched, so we need to call it after the downcast // localizedDescription is statically dispatched, so we need to call it after the downcast
if let error = error as? Pachyderm.Client.Error { if let error = error as? Pachyderm.Client.Error {
@ -73,7 +73,7 @@ extension ToastConfiguration {
} }
} }
init(from error: Error, with title: String, in viewController: TuskerNavigationDelegate, retryAction: @Sendable @escaping @MainActor (ToastView) async -> Void) { init(from error: Error, with title: String, in viewController: TuskerNavigationDelegate, retryAction: @escaping @MainActor (ToastView) async -> Void) {
self.init(from: error, with: title, in: viewController) { toast in self.init(from: error, with: title, in: viewController) { toast in
Task { Task {
await retryAction(toast) await retryAction(toast)