Compare commits
7 Commits
a9a518c6c1
...
0f6492a051
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 0f6492a051 | |
Shadowfacts | b235f0e826 | |
Shadowfacts | 27d44340e8 | |
Shadowfacts | fc26c9fb54 | |
Shadowfacts | ba60f92223 | |
Shadowfacts | c489d018bd | |
Shadowfacts | c2402303cc |
|
@ -18,19 +18,23 @@ class ActionViewController: UIViewController {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
findURLFromWebPage { (components) in
|
findURLFromWebPage { (components) in
|
||||||
if let components = components {
|
DispatchQueue.main.async {
|
||||||
self.searchForURLInApp(components)
|
if let components {
|
||||||
} else {
|
self.searchForURLInApp(components)
|
||||||
self.findURLItem { (components) in
|
} else {
|
||||||
if let components = components {
|
self.findURLItem { (components) in
|
||||||
self.searchForURLInApp(components)
|
if let components {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.searchForURLInApp(components)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func findURLFromWebPage(completion: @escaping (URLComponents?) -> Void) {
|
private func findURLFromWebPage(completion: @escaping @Sendable (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 {
|
||||||
|
@ -54,7 +58,7 @@ class ActionViewController: UIViewController {
|
||||||
completion(nil)
|
completion(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func findURLItem(completion: @escaping (URLComponents?) -> Void) {
|
private func findURLItem(completion: @escaping @Sendable (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 {
|
||||||
|
|
|
@ -15,7 +15,8 @@ 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]
|
||||||
|
|
|
@ -44,11 +44,7 @@ class AutocompleteEmojisController: ViewController {
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func queryChanged(_ query: String) async {
|
private func queryChanged(_ query: String) async {
|
||||||
var emojis = await withCheckedContinuation { continuation in
|
var emojis = await composeController.mastodonController.getCustomEmojis()
|
||||||
composeController.mastodonController.getCustomEmojis {
|
|
||||||
continuation.resume(returning: $0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard !Task.isCancelled else {
|
guard !Task.isCancelled else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
public protocol DuckableViewController: UIViewController {
|
public protocol DuckableViewController: UIViewController {
|
||||||
func duckableViewControllerShouldDuck() -> DuckAttemptAction
|
func duckableViewControllerShouldDuck() -> DuckAttemptAction
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
public class InstanceFeatures: ObservableObject {
|
public final 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>()
|
||||||
|
|
|
@ -12,7 +12,7 @@ import WebURL
|
||||||
/**
|
/**
|
||||||
The base Mastodon API client.
|
The base Mastodon API client.
|
||||||
*/
|
*/
|
||||||
public class Client {
|
public struct Client: Sendable {
|
||||||
|
|
||||||
public typealias Callback<Result: Decodable> = (Response<Result>) -> Void
|
public typealias Callback<Result: Decodable> = (Response<Result>) -> Void
|
||||||
|
|
||||||
|
@ -20,8 +20,6 @@ public class Client {
|
||||||
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?
|
||||||
|
|
||||||
|
@ -61,9 +59,11 @@ public class Client {
|
||||||
return encoder
|
return encoder
|
||||||
}()
|
}()
|
||||||
|
|
||||||
public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
|
public init(baseURL: URL, accessToken: String? = nil, clientID: String? = nil, clientSecret: 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,14 +150,7 @@ public class Client {
|
||||||
"scopes" => scopes.scopeString,
|
"scopes" => scopes.scopeString,
|
||||||
"website" => website?.absoluteString
|
"website" => website?.absoluteString
|
||||||
]))
|
]))
|
||||||
run(request) { result in
|
run(request, completion: completion)
|
||||||
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>) {
|
||||||
|
@ -169,12 +162,7 @@ public class Client {
|
||||||
"redirect_uri" => redirectURI,
|
"redirect_uri" => redirectURI,
|
||||||
"scope" => scopes.scopeString,
|
"scope" => scopes.scopeString,
|
||||||
]))
|
]))
|
||||||
run(request) { result in
|
run(request, completion: completion)
|
||||||
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 {
|
||||||
|
@ -198,21 +186,16 @@ public class Client {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public func nodeInfo(completion: @escaping Callback<NodeInfo>) {
|
public func nodeInfo() async throws -> NodeInfo {
|
||||||
let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo")
|
let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo")
|
||||||
run(wellKnown) { result in
|
let wellKnownResults = try await run(wellKnown).0
|
||||||
switch result {
|
if let url = wellKnownResults.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
|
||||||
case let .failure(error):
|
let href = WebURL(url.href),
|
||||||
completion(.failure(error))
|
href.host == WebURL(self.baseURL)?.host {
|
||||||
|
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
|
||||||
case let .success(wellKnown, _):
|
return try await run(nodeInfo).0
|
||||||
if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
|
} else {
|
||||||
let href = WebURL(url.href),
|
throw NodeInfoError.noWellKnownLink
|
||||||
href.host == WebURL(self.baseURL)?.host {
|
|
||||||
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
|
|
||||||
self.run(nodeInfo, completion: completion)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -600,4 +583,15 @@ 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum SearchOperatorType: String, CaseIterable, Equatable {
|
public enum SearchOperatorType: String, CaseIterable, Equatable, Sendable {
|
||||||
case has
|
case has
|
||||||
case `is`
|
case `is`
|
||||||
case language
|
case language
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct StatusEdit: Decodable {
|
public struct StatusEdit: Decodable, Sendable {
|
||||||
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 {
|
||||||
case emojis
|
case emojis
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Poll: Decodable {
|
public struct Poll: Decodable, Sendable {
|
||||||
public let options: [Option]
|
public let options: [Option]
|
||||||
|
|
||||||
public struct Option: Decodable {
|
public struct Option: Decodable, Sendable {
|
||||||
public let title: String
|
public let title: String
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct StatusSource: Decodable {
|
public struct StatusSource: Decodable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let text: String
|
public let text: String
|
||||||
public let spoilerText: String
|
public let spoilerText: String
|
||||||
|
|
|
@ -49,7 +49,7 @@ public class InstanceSelector {
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension InstanceSelector {
|
public extension InstanceSelector {
|
||||||
struct Instance: Codable {
|
struct Instance: Codable, Sendable {
|
||||||
public let domain: String
|
public let domain: String
|
||||||
public let description: String
|
public let description: String
|
||||||
public let proxiedThumbnailURL: URL
|
public let proxiedThumbnailURL: URL
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
public enum PostVisibility: Codable, Hashable, CaseIterable {
|
public enum PostVisibility: Codable, Hashable, CaseIterable, Sendable {
|
||||||
case serverDefault
|
case serverDefault
|
||||||
case visibility(Visibility)
|
case visibility(Visibility)
|
||||||
|
|
||||||
|
@ -59,6 +59,7 @@ 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:
|
||||||
|
|
|
@ -12,12 +12,14 @@ 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)
|
||||||
|
@ -33,6 +35,7 @@ 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)
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
public enum StatusSwipeAction: String, Codable, Hashable, CaseIterable {
|
public enum StatusSwipeAction: String, Codable, Hashable, CaseIterable, Sendable {
|
||||||
case reply
|
case reply
|
||||||
case favorite
|
case favorite
|
||||||
case reblog
|
case reblog
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
public struct UserAccountInfo: Equatable, Hashable, Identifiable {
|
public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let instanceURL: URL
|
public let instanceURL: URL
|
||||||
public let clientID: String
|
public let clientID: String
|
||||||
|
|
|
@ -12,7 +12,7 @@ import UserAccounts
|
||||||
import InstanceFeatures
|
import InstanceFeatures
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
|
final class ShareMastodonContext: ComposeMastodonContext, ObservableObject, Sendable {
|
||||||
let accountInfo: UserAccountInfo?
|
let accountInfo: UserAccountInfo?
|
||||||
let client: Client
|
let client: Client
|
||||||
let instanceFeatures: InstanceFeatures
|
let instanceFeatures: InstanceFeatures
|
||||||
|
@ -20,7 +20,9 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
|
||||||
@MainActor
|
@MainActor
|
||||||
private var customEmojis: [Emoji]?
|
private var customEmojis: [Emoji]?
|
||||||
|
|
||||||
@Published var ownAccount: Account?
|
@MainActor
|
||||||
|
@Published
|
||||||
|
private(set) var ownAccount: Account?
|
||||||
|
|
||||||
init(accountInfo: UserAccountInfo) {
|
init(accountInfo: UserAccountInfo) {
|
||||||
self.accountInfo = accountInfo
|
self.accountInfo = accountInfo
|
||||||
|
@ -29,16 +31,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
|
||||||
|
|
||||||
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: NodeInfo? = await withCheckedContinuation({ continuation in
|
async let nodeInfo = try? await client.nodeInfo()
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
@ -66,15 +59,13 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func getCustomEmojis(completion: @escaping ([Emoji]) -> Void) {
|
func getCustomEmojis() async -> [Emoji] {
|
||||||
if let customEmojis {
|
if let customEmojis {
|
||||||
completion(customEmojis)
|
return 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -54,6 +54,7 @@ struct SwitchAccountContainerView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
private struct AccountButtonLabel: View {
|
private struct AccountButtonLabel: View {
|
||||||
static let urlSession = URLSession(configuration: .ephemeral)
|
static let urlSession = URLSession(configuration: .ephemeral)
|
||||||
|
|
||||||
|
|
|
@ -314,6 +314,7 @@
|
||||||
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 */; };
|
||||||
|
@ -722,6 +723,7 @@
|
||||||
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>"; };
|
||||||
|
@ -1502,6 +1504,7 @@
|
||||||
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 */,
|
||||||
|
@ -2149,6 +2152,7 @@
|
||||||
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 */,
|
||||||
|
|
|
@ -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")
|
||||||
|
|
||||||
class MastodonController: ObservableObject {
|
final class MastodonController: ObservableObject, Sendable {
|
||||||
|
|
||||||
|
@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,10 +36,12 @@ class MastodonController: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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 = [:]
|
||||||
}
|
}
|
||||||
|
@ -48,26 +50,31 @@ class MastodonController: ObservableObject {
|
||||||
nonisolated let persistentContainer: MastodonCachePersistentStore// = MastodonCachePersistentStore(for: accountInfo, transient: transient)
|
nonisolated let persistentContainer: MastodonCachePersistentStore// = MastodonCachePersistentStore(for: accountInfo, transient: transient)
|
||||||
|
|
||||||
let instanceURL: URL
|
let instanceURL: URL
|
||||||
var accountInfo: UserAccountInfo?
|
let accountInfo: UserAccountInfo?
|
||||||
var accountPreferences: AccountPreferences!
|
@MainActor
|
||||||
|
private(set) var accountPreferences: AccountPreferences!
|
||||||
|
|
||||||
let client: Client!
|
private(set) var client: Client!
|
||||||
let instanceFeatures = InstanceFeatures()
|
let instanceFeatures = InstanceFeatures()
|
||||||
|
|
||||||
@Published private(set) var account: AccountMO?
|
@MainActor @Published private(set) var account: AccountMO?
|
||||||
@Published private(set) var instance: InstanceV1?
|
@MainActor @Published private(set) var instance: InstanceV1?
|
||||||
@Published private var instanceV2: InstanceV2?
|
@MainActor @Published private var instanceV2: InstanceV2?
|
||||||
@Published private(set) var instanceInfo: InstanceInfo!
|
@MainActor @Published private(set) var instanceInfo: InstanceInfo!
|
||||||
@Published private(set) var nodeInfo: NodeInfo!
|
@MainActor @Published private(set) var nodeInfo: NodeInfo!
|
||||||
@Published private(set) var lists: [List] = []
|
@MainActor @Published private(set) var lists: [List] = []
|
||||||
@Published private(set) var customEmojis: [Emoji]?
|
@MainActor @Published private(set) var customEmojis: [Emoji]?
|
||||||
@Published private(set) var followedHashtags: [FollowedHashtag] = []
|
@MainActor @Published private(set) var followedHashtags: [FollowedHashtag] = []
|
||||||
@Published private(set) var filters: [FilterMO] = []
|
@MainActor @Published private(set) var filters: [FilterMO] = []
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
private var pendingOwnInstanceRequestCallbacks = [(Result<InstanceV1, Client.Error>) -> Void]()
|
@MainActor
|
||||||
private var ownInstanceRequest: URLSessionTask?
|
private var fetchOwnInstanceTask: Task<InstanceV1, any Error>?
|
||||||
|
@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
|
||||||
|
@ -222,22 +229,37 @@ class MastodonController: ObservableObject {
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
async let ownAccount = try getOwnAccount()
|
_ = try await getOwnAccount()
|
||||||
async let ownInstance = try getOwnInstance()
|
|
||||||
|
|
||||||
_ = try await (ownAccount, ownInstance)
|
|
||||||
|
|
||||||
if instanceFeatures.hasMastodonVersion(4, 0, 0) {
|
|
||||||
async let _ = try? getOwnInstanceV2()
|
|
||||||
}
|
|
||||||
|
|
||||||
loadLists()
|
|
||||||
_ = await loadFilters()
|
|
||||||
await loadServerPreferences()
|
|
||||||
} catch {
|
} catch {
|
||||||
Logging.general.error("MastodonController initialization failed: \(String(describing: error))")
|
logger.error("Fetch own account failed: \(String(describing: error))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let instanceTask = Task {
|
||||||
|
do {
|
||||||
|
_ = try await getOwnInstance()
|
||||||
|
if instanceFeatures.hasMastodonVersion(4, 0, 0) {
|
||||||
|
_ = try? await getOwnInstanceV2()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("Fetch instance failed: \(String(describing: error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
_ = await instanceTask.value
|
||||||
|
await loadLists()
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
_ = await instanceTask.value
|
||||||
|
_ = await loadFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
_ = await instanceTask.value
|
||||||
|
await loadServerPreferences()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
@ -250,131 +272,93 @@ class MastodonController: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getOwnAccount(completion: ((Result<AccountMO, Client.Error>) -> Void)? = nil) {
|
@MainActor
|
||||||
if let account {
|
|
||||||
completion?(.success(account))
|
|
||||||
} else {
|
|
||||||
let request = Client.getSelfAccount()
|
|
||||||
run(request) { response in
|
|
||||||
switch response {
|
|
||||||
case let .failure(error):
|
|
||||||
completion?(.failure(error))
|
|
||||||
|
|
||||||
case let .success(account, _):
|
|
||||||
let context = self.persistentContainer.viewContext
|
|
||||||
context.perform {
|
|
||||||
let accountMO: AccountMO
|
|
||||||
if let existing = self.persistentContainer.account(for: account.id, in: context) {
|
|
||||||
accountMO = existing
|
|
||||||
existing.updateFrom(apiAccount: account, container: self.persistentContainer)
|
|
||||||
} else {
|
|
||||||
accountMO = self.persistentContainer.addOrUpdateSynchronously(account: account, in: context)
|
|
||||||
}
|
|
||||||
accountMO.active = true
|
|
||||||
self.account = accountMO
|
|
||||||
completion?(.success(accountMO))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getOwnAccount() async throws -> AccountMO {
|
func getOwnAccount() async throws -> AccountMO {
|
||||||
if let account = account {
|
if let account {
|
||||||
return account
|
return account
|
||||||
|
} else if let fetchOwnAccountTask {
|
||||||
|
return try await fetchOwnAccountTask.value.value
|
||||||
} else {
|
} else {
|
||||||
return try await withCheckedThrowingContinuation({ continuation in
|
let task = Task {
|
||||||
self.getOwnAccount { result in
|
let account = try await run(Client.getSelfAccount()).0
|
||||||
continuation.resume(with: result)
|
|
||||||
|
let context = persistentContainer.viewContext
|
||||||
|
// this closure is declared separately so we can tell the compiler it's Sendable
|
||||||
|
let performBlock: @MainActor @Sendable () -> MainThreadBox<AccountMO> = {
|
||||||
|
let accountMO: AccountMO
|
||||||
|
if let existing = self.persistentContainer.account(for: account.id, in: context) {
|
||||||
|
accountMO = existing
|
||||||
|
existing.updateFrom(apiAccount: account, container: self.persistentContainer)
|
||||||
|
} else {
|
||||||
|
accountMO = self.persistentContainer.addOrUpdateSynchronously(account: account, in: context)
|
||||||
|
}
|
||||||
|
// TODO: is AccountMO.active used anywhere?
|
||||||
|
accountMO.active = true
|
||||||
|
self.account = accountMO
|
||||||
|
return MainThreadBox(value: 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: ((InstanceV1) -> Void)? = nil) {
|
func getOwnInstance(completion: (@Sendable (InstanceV1) -> Void)? = nil) {
|
||||||
getOwnInstanceInternal(retryAttempt: 0) {
|
Task.detached {
|
||||||
if case let .success(instance) = $0 {
|
let ownInstance = try? await self.getOwnInstance()
|
||||||
completion?(instance)
|
if let ownInstance {
|
||||||
|
completion?(ownInstance)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func getOwnInstance() async throws -> InstanceV1 {
|
func getOwnInstance() async throws -> InstanceV1 {
|
||||||
return try await withCheckedThrowingContinuation({ continuation in
|
if let instance {
|
||||||
getOwnInstanceInternal(retryAttempt: 0) { result in
|
return instance
|
||||||
continuation.resume(with: result)
|
} else if let fetchOwnInstanceTask {
|
||||||
}
|
return try await fetchOwnInstanceTask.value
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
} else {
|
||||||
if let completion = completion {
|
let task = Task {
|
||||||
pendingOwnInstanceRequestCallbacks.append(completion)
|
let instance = try await retrying("Fetch Own Instance") {
|
||||||
}
|
try await run(Client.getInstanceV1()).0
|
||||||
|
}
|
||||||
|
self.instance = instance
|
||||||
|
|
||||||
if ownInstanceRequest == nil {
|
Task {
|
||||||
let request = Client.getInstanceV1()
|
await fetchNodeInfo()
|
||||||
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
|
|
||||||
|
|
||||||
for completion in self.pendingOwnInstanceRequestCallbacks {
|
|
||||||
completion(.success(instance))
|
|
||||||
}
|
|
||||||
self.pendingOwnInstanceRequestCallbacks = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
client.nodeInfo { result in
|
return instance
|
||||||
switch result {
|
|
||||||
case let .failure(error):
|
|
||||||
print("Unable to get node info: \(error)")
|
|
||||||
|
|
||||||
case let .success(nodeInfo, _):
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.nodeInfo = nodeInfo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
fetchOwnInstanceTask = task
|
||||||
|
return try await task.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func fetchNodeInfo() async {
|
||||||
|
if let nodeInfo = try? await client.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
|
||||||
}
|
}
|
||||||
|
@ -425,43 +409,43 @@ class MastodonController: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCustomEmojis(completion: @escaping ([Emoji]) -> Void) {
|
@MainActor
|
||||||
if let emojis = self.customEmojis {
|
func getCustomEmojis() async -> [Emoji] {
|
||||||
completion(emojis)
|
if let customEmojis {
|
||||||
|
return customEmojis
|
||||||
|
} else if let fetchCustomEmojisTask {
|
||||||
|
return await fetchCustomEmojisTask.value
|
||||||
} else {
|
} else {
|
||||||
let request = Client.getCustomEmoji()
|
let task = Task {
|
||||||
run(request) { (response) in
|
let emojis = (try? await run(Client.getCustomEmoji()).0) ?? []
|
||||||
if case let .success(emojis, _) = response {
|
customEmojis = emojis
|
||||||
DispatchQueue.main.async {
|
return emojis
|
||||||
self.customEmojis = emojis
|
|
||||||
}
|
|
||||||
completion(emojis)
|
|
||||||
} else {
|
|
||||||
completion([])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
fetchCustomEmojisTask = task
|
||||||
|
return await task.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadLists() {
|
private func loadLists() async {
|
||||||
let req = Client.getLists()
|
let req = Client.getLists()
|
||||||
run(req) { response in
|
guard let (lists, _) = try? await run(req) else {
|
||||||
if case .success(let lists, _) = response {
|
return
|
||||||
DispatchQueue.main.async {
|
}
|
||||||
self.lists = lists.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
|
let sorted = lists.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
|
||||||
}
|
await MainActor.run {
|
||||||
let context = self.persistentContainer.backgroundContext
|
self.lists = sorted
|
||||||
context.perform {
|
}
|
||||||
for list in lists {
|
|
||||||
if let existing = try? context.fetch(ListMO.fetchRequest(id: list.id)).first {
|
let context = persistentContainer.backgroundContext
|
||||||
existing.updateFrom(apiList: list)
|
await context.perform {
|
||||||
} else {
|
for list in lists {
|
||||||
_ = ListMO(apiList: list, context: context)
|
if let existing = try? context.fetch(ListMO.fetchRequest(id: list.id)).first {
|
||||||
}
|
existing.updateFrom(apiList: list)
|
||||||
}
|
} else {
|
||||||
self.persistentContainer.save(context: context)
|
_ = ListMO(apiList: list, context: context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.persistentContainer.save(context: context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -548,6 +532,7 @@ class MastodonController: ObservableObject {
|
||||||
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]()
|
||||||
|
|
||||||
|
@ -600,6 +585,7 @@ class MastodonController: ObservableObject {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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(
|
||||||
|
|
|
@ -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")
|
||||||
|
|
||||||
@UIApplicationMain
|
@main
|
||||||
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 {
|
||||||
|
|
|
@ -11,14 +11,19 @@ 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
|
||||||
|
|
||||||
class ImageCache {
|
final class ImageCache: @unchecked Sendable {
|
||||||
|
|
||||||
|
@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
|
||||||
|
@ -30,6 +35,7 @@ class ImageCache {
|
||||||
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))
|
||||||
|
@ -37,7 +43,7 @@ class ImageCache {
|
||||||
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: ((Data?, UIImage?) -> Void)?) -> Request? {
|
func get(_ url: URL, loadOriginal: Bool = false, completion: (@Sendable (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)
|
||||||
|
@ -47,7 +53,7 @@ class ImageCache {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getFromSource(_ url: URL, completion: ((Data?, UIImage?) -> Void)?) -> Request? {
|
func getFromSource(_ url: URL, completion: (@Sendable (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 {
|
||||||
|
|
|
@ -550,14 +550,19 @@ 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 changedHashtags {
|
if hashtags {
|
||||||
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil)
|
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil)
|
||||||
}
|
}
|
||||||
if changedInstances {
|
if instances {
|
||||||
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil)
|
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil)
|
||||||
}
|
}
|
||||||
for id in changedTimelinePositions {
|
for id in timelinePositions {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
@ -565,7 +570,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
||||||
timelinePosition.changedRemotely()
|
timelinePosition.changedRemotely()
|
||||||
NotificationCenter.default.post(name: .timelinePositionChanged, object: timelinePosition)
|
NotificationCenter.default.post(name: .timelinePositionChanged, object: timelinePosition)
|
||||||
}
|
}
|
||||||
if changedAccountPrefs {
|
if accountPrefs {
|
||||||
NotificationCenter.default.post(name: .accountPreferencesChangedRemotely, object: nil)
|
NotificationCenter.default.post(name: .accountPreferencesChangedRemotely, object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Copied from https://github.com/ChimeHQ/ConcurrencyPlus/blob/fe3b3fd5436b196d8c5211ab2cc4b69fc35524fe/Sources/ConcurrencyPlus/MainActor%2BUnsafe.swift
|
Copied from https://github.com/ChimeHQ/ConcurrencyPlus/blob/daf69ed837fa04d4ba666f5a99378cf1815f0dab/Sources/ConcurrencyPlus/MainActor%2BUnsafe.swift
|
||||||
|
|
||||||
Copyright (c) 2022, Chime
|
Copyright (c) 2022, Chime
|
||||||
All rights reserved.
|
All rights reserved.
|
||||||
|
@ -46,10 +46,23 @@ 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.
|
||||||
@MainActor(unsafe)
|
@_unavailableFromAsync
|
||||||
static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T {
|
static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T {
|
||||||
dispatchPrecondition(condition: .onQueue(.main))
|
if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) {
|
||||||
|
return try MainActor.assumeIsolated(body)
|
||||||
|
}
|
||||||
|
|
||||||
return try body()
|
dispatchPrecondition(condition: .onQueue(.main))
|
||||||
|
return try withoutActuallyEscaping(body) { fn in
|
||||||
|
try unsafeBitCast(fn, to: (() throws -> T).self)()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@_unavailableFromAsync
|
||||||
|
@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)()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ 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, *) {
|
||||||
|
|
|
@ -15,7 +15,10 @@ 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? {
|
||||||
if Preferences.shared.grayscaleImages,
|
let grayscale = MainActor.runUnsafelyMaybeIntroducingDataRace {
|
||||||
|
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)
|
||||||
|
@ -24,6 +27,18 @@ 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) {
|
||||||
|
@ -35,6 +50,21 @@ 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) {
|
||||||
|
@ -56,6 +86,18 @@ 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
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct MenuController {
|
struct MenuController {
|
||||||
|
|
||||||
static let composeCommand: UIKeyCommand = {
|
static let composeCommand: UIKeyCommand = {
|
||||||
|
|
|
@ -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, *)
|
||||||
class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable> {
|
final class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable>: @unchecked Sendable {
|
||||||
#if os(visionOS)
|
#if os(visionOS)
|
||||||
private let lock = OSAllocatedUnfairLock(initialState: [Key: Value]())
|
private let lock = OSAllocatedUnfairLock(initialState: [Key: Value]())
|
||||||
#else
|
#else
|
||||||
|
|
|
@ -11,6 +11,7 @@ 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:
|
||||||
|
@ -29,6 +30,7 @@ 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 }
|
||||||
|
@ -40,6 +42,7 @@ 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
|
||||||
|
@ -53,6 +56,7 @@ 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
|
||||||
|
@ -69,6 +73,7 @@ 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 {
|
||||||
|
@ -86,6 +91,7 @@ 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 {
|
||||||
|
@ -100,6 +106,7 @@ 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
|
||||||
|
@ -124,11 +131,10 @@ 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")
|
||||||
|
|
|
@ -10,10 +10,14 @@ 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),
|
||||||
|
@ -23,6 +27,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
@ -39,6 +44,7 @@ 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)
|
||||||
|
@ -46,8 +52,6 @@ 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]
|
||||||
|
@ -55,7 +59,6 @@ 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] {
|
||||||
|
@ -65,11 +68,6 @@ 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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ 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 }
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
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)
|
||||||
|
|
|
@ -18,6 +18,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
|
@ -296,7 +296,7 @@ extension ConversationCollectionViewController {
|
||||||
case mainStatus
|
case mainStatus
|
||||||
case childThread(firstStatusID: String)
|
case childThread(firstStatusID: String)
|
||||||
}
|
}
|
||||||
enum Item: Hashable {
|
enum Item: Hashable, Sendable {
|
||||||
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.status.id == $1.status.id } && aInline == bInline
|
return a.count == b.count && zip(a, b).allSatisfy { $0.statusID == $1.statusID } && 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.status.id)
|
hasher.combine(thread.statusID)
|
||||||
}
|
}
|
||||||
hasher.combine(inline)
|
hasher.combine(inline)
|
||||||
case .loadingIndicator:
|
case .loadingIndicator:
|
||||||
|
|
|
@ -9,15 +9,20 @@
|
||||||
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
|
||||||
|
|
|
@ -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
|
||||||
class RedirectBlocker: NSObject, URLSessionTaskDelegate {
|
final class RedirectBlocker: NSObject, URLSessionTaskDelegate, Sendable {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -166,13 +166,15 @@ class IssueReporterViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension IssueReporterViewController: MFMailComposeViewControllerDelegate {
|
extension IssueReporterViewController: MFMailComposeViewControllerDelegate {
|
||||||
func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
|
nonisolated func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
|
||||||
controller.dismiss(animated: true) {
|
MainActor.runUnsafely {
|
||||||
if result == .cancelled {
|
controller.dismiss(animated: true) {
|
||||||
// don't dismiss ourself, to allowe the user to send the report a different way
|
if result == .cancelled {
|
||||||
} else {
|
// don't dismiss ourself, to allowe the user to send the report a different way
|
||||||
self.finishedReport()
|
} else {
|
||||||
self.doDismiss()
|
self.finishedReport()
|
||||||
|
self.doDismiss()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ import WebURLFoundationExtras
|
||||||
|
|
||||||
class ExploreViewController: UIViewController, UICollectionViewDelegate, CollectionViewController {
|
class ExploreViewController: UIViewController, UICollectionViewDelegate, CollectionViewController {
|
||||||
|
|
||||||
weak var mastodonController: MastodonController!
|
private let mastodonController: MastodonController
|
||||||
|
|
||||||
var collectionView: UICollectionView!
|
var collectionView: UICollectionView!
|
||||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
|
|
|
@ -20,8 +20,11 @@ class FeaturedProfileCollectionViewCell: UICollectionViewCell {
|
||||||
|
|
||||||
var account: Account?
|
var account: Account?
|
||||||
|
|
||||||
private var avatarRequest: ImageCache.Request?
|
private var accountImagesTask: Task<Void, Never>?
|
||||||
private var headerRequest: ImageCache.Request?
|
|
||||||
|
deinit {
|
||||||
|
accountImagesTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
override func awakeFromNib() {
|
override func awakeFromNib() {
|
||||||
super.awakeFromNib()
|
super.awakeFromNib()
|
||||||
|
@ -62,37 +65,35 @@ class FeaturedProfileCollectionViewCell: UICollectionViewCell {
|
||||||
noteTextView.setEmojis(account.emojis, identifier: account.id)
|
noteTextView.setEmojis(account.emojis, identifier: account.id)
|
||||||
|
|
||||||
avatarImageView.image = nil
|
avatarImageView.image = nil
|
||||||
if let avatar = account.avatar {
|
headerImageView.image = nil
|
||||||
avatarRequest = ImageCache.avatars.get(avatar) { [weak self] (_, image) in
|
|
||||||
defer {
|
accountImagesTask?.cancel()
|
||||||
self?.avatarRequest = nil
|
accountImagesTask = Task {
|
||||||
}
|
await updateImages(account: account)
|
||||||
guard let self = self,
|
}
|
||||||
let image = image,
|
}
|
||||||
self.account?.id == account.id else {
|
|
||||||
|
private nonisolated func updateImages(account: Account) async {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
DispatchQueue.main.async {
|
await MainActor.run {
|
||||||
self.avatarImageView.image = image
|
self.avatarImageView.image = image
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
group.addTask {
|
||||||
|
guard let header = account.header,
|
||||||
headerImageView.image = nil
|
let image = await ImageCache.headers.get(header).1 else {
|
||||||
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
|
||||||
}
|
}
|
||||||
DispatchQueue.main.async {
|
await MainActor.run {
|
||||||
self.headerImageView.image = image
|
self.headerImageView.image = image
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await group.waitForAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ import Pachyderm
|
||||||
|
|
||||||
class ProfileDirectoryViewController: UIViewController {
|
class ProfileDirectoryViewController: UIViewController {
|
||||||
|
|
||||||
weak var mastodonController: MastodonController!
|
private let mastodonController: MastodonController
|
||||||
|
|
||||||
private var collectionView: UICollectionView!
|
private var collectionView: UICollectionView!
|
||||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
|
|
|
@ -11,7 +11,7 @@ import Pachyderm
|
||||||
|
|
||||||
class SuggestedProfilesViewController: UIViewController, CollectionViewController {
|
class SuggestedProfilesViewController: UIViewController, CollectionViewController {
|
||||||
|
|
||||||
weak var mastodonController: MastodonController!
|
private let mastodonController: MastodonController
|
||||||
|
|
||||||
var collectionView: UICollectionView!
|
var collectionView: UICollectionView!
|
||||||
private var layout: MultiColumnCollectionViewLayout!
|
private var layout: MultiColumnCollectionViewLayout!
|
||||||
|
@ -95,7 +95,9 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle
|
||||||
|
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
snapshot.appendSections([.accounts])
|
snapshot.appendSections([.accounts])
|
||||||
await dataSource.apply(snapshot)
|
await MainActor.run {
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let request = Client.getSuggestions(limit: 80)
|
let request = Client.getSuggestions(limit: 80)
|
||||||
|
@ -108,7 +110,9 @@ 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 dataSource.apply(snapshot)
|
await MainActor.run {
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
state = .loaded
|
state = .loaded
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
@ -13,7 +13,7 @@ import Combine
|
||||||
|
|
||||||
class TrendingHashtagsViewController: UIViewController {
|
class TrendingHashtagsViewController: UIViewController {
|
||||||
|
|
||||||
weak var mastodonController: MastodonController!
|
private let mastodonController: MastodonController
|
||||||
|
|
||||||
private var collectionView: UICollectionView!
|
private var collectionView: UICollectionView!
|
||||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
|
@ -107,7 +107,9 @@ 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 dataSource.apply(snapshot)
|
await MainActor.run {
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let request = self.request(offset: nil)
|
let request = self.request(offset: nil)
|
||||||
|
@ -115,10 +117,14 @@ 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 dataSource.apply(snapshot)
|
await MainActor.run {
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
snapshot.deleteItems([.loadingIndicator])
|
snapshot.deleteItems([.loadingIndicator])
|
||||||
await dataSource.apply(snapshot)
|
await MainActor.run {
|
||||||
|
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
|
||||||
|
@ -140,7 +146,9 @@ 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 dataSource.apply(snapshot)
|
await MainActor.run {
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
for await _ in confirmLoadMore.values {
|
for await _ in confirmLoadMore.values {
|
||||||
break
|
break
|
||||||
|
@ -148,10 +156,14 @@ class TrendingHashtagsViewController: UIViewController {
|
||||||
|
|
||||||
snapshot.deleteItems([.confirmLoadMore(false)])
|
snapshot.deleteItems([.confirmLoadMore(false)])
|
||||||
snapshot.appendItems([.confirmLoadMore(true)])
|
snapshot.appendItems([.confirmLoadMore(true)])
|
||||||
await dataSource.apply(snapshot, animatingDifferences: false)
|
await MainActor.run {
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
snapshot.appendItems([.loadingIndicator])
|
snapshot.appendItems([.loadingIndicator])
|
||||||
await dataSource.apply(snapshot)
|
await MainActor.run {
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
@ -159,9 +171,13 @@ 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 dataSource.apply(snapshot)
|
await MainActor.run {
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await dataSource.apply(origSnapshot)
|
await MainActor.run {
|
||||||
|
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)
|
||||||
|
|
|
@ -14,7 +14,7 @@ import Combine
|
||||||
|
|
||||||
class TrendingLinksViewController: UIViewController, CollectionViewController {
|
class TrendingLinksViewController: UIViewController, CollectionViewController {
|
||||||
|
|
||||||
weak var mastodonController: MastodonController!
|
private let mastodonController: MastodonController
|
||||||
|
|
||||||
var collectionView: UICollectionView!
|
var collectionView: UICollectionView!
|
||||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
|
@ -128,7 +128,9 @@ 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 dataSource.apply(snapshot)
|
await MainActor.run {
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let request = Client.getTrendingLinks()
|
let request = Client.getTrendingLinks()
|
||||||
|
@ -137,9 +139,13 @@ 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 dataSource.apply(snapshot)
|
await MainActor.run {
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await dataSource.apply(NSDiffableDataSourceSnapshot())
|
await MainActor.run {
|
||||||
|
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)
|
||||||
|
@ -161,7 +167,9 @@ 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 dataSource.apply(snapshot)
|
await MainActor.run {
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
for await _ in confirmLoadMore.values {
|
for await _ in confirmLoadMore.values {
|
||||||
break
|
break
|
||||||
|
@ -169,11 +177,15 @@ 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 dataSource.apply(snapshot, animatingDifferences: false)
|
await MainActor.run {
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
snapshot.appendSections([.loadingIndicator])
|
snapshot.appendSections([.loadingIndicator])
|
||||||
snapshot.appendItems([.loadingIndicator], toSection: .loadingIndicator)
|
snapshot.appendItems([.loadingIndicator], toSection: .loadingIndicator)
|
||||||
await dataSource.apply(snapshot)
|
await MainActor.run {
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
@ -181,9 +193,13 @@ 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 dataSource.apply(snapshot)
|
await MainActor.run {
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await dataSource.apply(origSnapshot)
|
await MainActor.run {
|
||||||
|
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()
|
||||||
|
|
|
@ -11,7 +11,7 @@ import Pachyderm
|
||||||
|
|
||||||
class TrendingStatusesViewController: UIViewController, CollectionViewController {
|
class TrendingStatusesViewController: UIViewController, CollectionViewController {
|
||||||
|
|
||||||
weak var mastodonController: MastodonController!
|
private let mastodonController: MastodonController
|
||||||
let filterer: Filterer
|
let filterer: Filterer
|
||||||
|
|
||||||
var collectionView: UICollectionView! {
|
var collectionView: UICollectionView! {
|
||||||
|
@ -126,7 +126,9 @@ 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 dataSource.apply(snapshot)
|
await MainActor.run {
|
||||||
|
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()
|
||||||
|
@ -138,7 +140,9 @@ 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 dataSource.apply(snapshot)
|
await MainActor.run {
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
|
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
|
||||||
|
|
|
@ -238,7 +238,7 @@ class TrendsViewController: UIViewController, CollectionViewController {
|
||||||
}
|
}
|
||||||
isShowingTrends = shouldShowTrends
|
isShowingTrends = shouldShowTrends
|
||||||
guard shouldShowTrends else {
|
guard shouldShowTrends else {
|
||||||
await dataSource.apply(NSDiffableDataSourceSnapshot())
|
await apply(snapshot: 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 Task { @MainActor in
|
await MainActor.run {
|
||||||
self.dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
|
self.dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
|
||||||
}.value
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
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.
|
||||||
|
|
|
@ -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 avatarRequest: ImageCache.Request?
|
private var avatarTask: Task<Void, Never>?
|
||||||
|
|
||||||
init(account: UserAccountInfo, orientation: FastAccountSwitcherViewController.ItemOrientation) {
|
init(account: UserAccountInfo, orientation: FastAccountSwitcherViewController.ItemOrientation) {
|
||||||
self.orientation = orientation
|
self.orientation = orientation
|
||||||
|
@ -69,6 +69,10 @@ 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)
|
||||||
|
@ -133,16 +137,13 @@ class FastSwitchingAccountView: UIView {
|
||||||
instanceLabel.text = account.instanceURL.host!
|
instanceLabel.text = account.instanceURL.host!
|
||||||
}
|
}
|
||||||
let controller = MastodonController.getForAccount(account)
|
let controller = MastodonController.getForAccount(account)
|
||||||
controller.getOwnAccount { [weak self] (result) in
|
avatarTask = Task {
|
||||||
guard let self = self,
|
guard let account = try? await controller.getOwnAccount(),
|
||||||
case let .success(account) = result,
|
let avatar = account.avatar,
|
||||||
let avatar = account.avatar else { return }
|
let image = await ImageCache.avatars.get(avatar).1 else {
|
||||||
self.avatarRequest = ImageCache.avatars.get(avatar) { [weak self] (_, image) in
|
return
|
||||||
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!)"
|
||||||
|
|
|
@ -57,12 +57,14 @@ class GalleryFallbackViewController: QLPreviewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension GalleryFallbackViewController: QLPreviewControllerDataSource {
|
extension GalleryFallbackViewController: QLPreviewControllerDataSource {
|
||||||
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
|
nonisolated func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
|
nonisolated func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
|
||||||
return previewItem
|
return MainActor.runUnsafely {
|
||||||
|
previewItem
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,7 @@ 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 }
|
||||||
|
|
|
@ -144,7 +144,9 @@ 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
|
||||||
self.centerImage()
|
MainActor.runUnsafely {
|
||||||
|
self.centerImage()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 }
|
||||||
self.imageRequest = nil
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
self.imageRequest = nil
|
||||||
self.loadingVC?.removeViewAndController()
|
self.loadingVC?.removeViewAndController()
|
||||||
self.createLargeImage(data: data, image: image, url: self.url)
|
self.createLargeImage(data: data, image: image, url: self.url)
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
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 }
|
||||||
|
|
|
@ -195,7 +195,9 @@ 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 dataSource.apply(snapshot)
|
await MainActor.run {
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (accounts, pagination) = try await results
|
let (accounts, pagination) = try await results
|
||||||
|
@ -210,7 +212,9 @@ 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 dataSource.apply(snapshot)
|
await MainActor.run {
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
state = .loaded
|
state = .loaded
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -221,7 +225,9 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
|
||||||
self.showToast(configuration: config, animated: true)
|
self.showToast(configuration: config, animated: true)
|
||||||
|
|
||||||
state = .unloaded
|
state = .unloaded
|
||||||
await dataSource.apply(.init())
|
await MainActor.run {
|
||||||
|
dataSource.apply(.init())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -236,7 +242,9 @@ 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 dataSource.apply(snapshot)
|
await MainActor.run {
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (accounts, pagination) = try await results
|
let (accounts, pagination) = try await results
|
||||||
|
@ -250,7 +258,9 @@ 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 dataSource.apply(snapshot)
|
await MainActor.run {
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
state = .loaded
|
state = .loaded
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -261,7 +271,9 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
|
||||||
self.showToast(configuration: config, animated: true)
|
self.showToast(configuration: config, animated: true)
|
||||||
|
|
||||||
state = .loaded
|
state = .loaded
|
||||||
await dataSource.apply(origSnapshot)
|
await MainActor.run {
|
||||||
|
dataSource.apply(origSnapshot)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,7 @@ 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 }
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ 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)
|
||||||
|
|
|
@ -11,7 +11,7 @@ import Combine
|
||||||
|
|
||||||
class MainSplitViewController: UISplitViewController {
|
class MainSplitViewController: UISplitViewController {
|
||||||
|
|
||||||
weak var mastodonController: MastodonController!
|
private let mastodonController: MastodonController
|
||||||
|
|
||||||
private var sidebar: MainSidebarViewController!
|
private var sidebar: MainSidebarViewController!
|
||||||
private var fastAccountSwitcher: FastAccountSwitcherViewController?
|
private var fastAccountSwitcher: FastAccountSwitcherViewController?
|
||||||
|
@ -481,6 +481,7 @@ 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):
|
||||||
|
|
|
@ -11,7 +11,7 @@ import ComposeUI
|
||||||
|
|
||||||
class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
|
class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
|
||||||
|
|
||||||
weak var mastodonController: MastodonController!
|
private let mastodonController: MastodonController
|
||||||
|
|
||||||
private var composePlaceholder: UIViewController!
|
private var composePlaceholder: UIViewController!
|
||||||
|
|
||||||
|
@ -207,6 +207,7 @@ 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:
|
||||||
|
|
|
@ -83,6 +83,7 @@ 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 }
|
||||||
|
|
|
@ -15,7 +15,7 @@ import Sentry
|
||||||
|
|
||||||
class NotificationsCollectionViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController {
|
class NotificationsCollectionViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController {
|
||||||
|
|
||||||
weak var mastodonController: MastodonController!
|
private let mastodonController: MastodonController
|
||||||
private let filterer: Filterer
|
private let filterer: Filterer
|
||||||
|
|
||||||
private let allowedTypes: [Pachyderm.Notification.Kind]
|
private let allowedTypes: [Pachyderm.Notification.Kind]
|
||||||
|
@ -273,8 +273,11 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dismissNotificationsInGroup(at indexPath: IndexPath) async {
|
private nonisolated func dismissNotificationsInGroup(at indexPath: IndexPath) async {
|
||||||
guard case .group(let group, let collapseState, let filterState) = dataSource.itemIdentifier(for: indexPath) else {
|
let item = await MainActor.run {
|
||||||
|
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
|
||||||
|
@ -295,7 +298,9 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = await MainActor.run {
|
||||||
|
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 {
|
||||||
|
|
|
@ -10,6 +10,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
@ -26,7 +27,7 @@ class InstanceSelectorTableViewController: UITableViewController {
|
||||||
|
|
||||||
weak var delegate: InstanceSelectorTableViewControllerDelegate?
|
weak var delegate: InstanceSelectorTableViewControllerDelegate?
|
||||||
|
|
||||||
var dataSource: DataSource!
|
var dataSource: UITableViewDiffableDataSource<Section, Item>!
|
||||||
var searchController: UISearchController!
|
var searchController: UISearchController!
|
||||||
|
|
||||||
var recommendedInstances: [InstanceSelector.Instance] = []
|
var recommendedInstances: [InstanceSelector.Instance] = []
|
||||||
|
@ -72,7 +73,7 @@ class InstanceSelectorTableViewController: UITableViewController {
|
||||||
tableView.estimatedRowHeight = 120
|
tableView.estimatedRowHeight = 120
|
||||||
createActivityIndicatorHeader()
|
createActivityIndicatorHeader()
|
||||||
|
|
||||||
dataSource = DataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
|
dataSource = UITableViewDiffableDataSource(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
|
||||||
|
@ -310,7 +311,7 @@ extension InstanceSelectorTableViewController {
|
||||||
case selected
|
case selected
|
||||||
case recommendedInstances
|
case recommendedInstances
|
||||||
}
|
}
|
||||||
enum Item: Equatable, Hashable {
|
enum Item: Equatable, Hashable, Sendable {
|
||||||
case selected(URL, InstanceV1)
|
case selected(URL, InstanceV1)
|
||||||
case recommended(InstanceSelector.Instance)
|
case recommended(InstanceSelector.Instance)
|
||||||
|
|
||||||
|
@ -337,9 +338,6 @@ extension InstanceSelectorTableViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class DataSource: UITableViewDiffableDataSource<Section, Item> {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension InstanceSelectorTableViewController: UISearchResultsUpdating {
|
extension InstanceSelectorTableViewController: UISearchResultsUpdating {
|
||||||
|
|
|
@ -145,27 +145,24 @@ class OnboardingViewController: UINavigationController {
|
||||||
throw Error.gettingAccessToken(error)
|
throw Error.gettingAccessToken(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// construct a temporary UserAccountInfo instance for the MastodonController to use to fetch its own account
|
// construct a temporary Client to use to fetch the user's account
|
||||||
let tempAccountInfo = UserAccountInfo(tempInstanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, accessToken: accessToken)
|
let tempClient = Client(baseURL: instanceURL, accessToken: accessToken, session: .appDefault)
|
||||||
mastodonController.accountInfo = tempAccountInfo
|
|
||||||
|
|
||||||
updateStatus("Checking Credentials")
|
updateStatus("Checking Credentials")
|
||||||
let ownAccount: AccountMO
|
let ownAccount: Account
|
||||||
do {
|
do {
|
||||||
ownAccount = try await retrying("Getting own account") {
|
ownAccount = try await retrying("Getting own account") {
|
||||||
try await mastodonController.getOwnAccount()
|
try await tempClient.run(Client.getSelfAccount()).0
|
||||||
}
|
}
|
||||||
} 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>(_ label: StaticString, action: () async throws -> T) async throws -> T {
|
private func retrying<T: Sendable>(_ 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()
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
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
|
||||||
|
|
|
@ -32,20 +32,19 @@ 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)
|
||||||
.onAppear(perform: self.loadImage)
|
.task {
|
||||||
|
await self.loadImage()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadImage() {
|
func loadImage() async {
|
||||||
let controller = MastodonController.getForAccount(localAccountInfo)
|
let controller = MastodonController.getForAccount(localAccountInfo)
|
||||||
controller.getOwnAccount { (result) in
|
guard let account = try? await controller.getOwnAccount(),
|
||||||
guard case let .success(account) = result,
|
let avatar = account.avatar,
|
||||||
let avatar = account.avatar else { return }
|
let image = await ImageCache.avatars.get(avatar).1 else {
|
||||||
_ = ImageCache.avatars.get(avatar) { (_, image) in
|
return
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.avatarImage = image
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
self.avatarImage = image
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -103,6 +103,7 @@ private struct WideCapsule: Shape {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
private protocol NavigationModePreview: UIView {
|
private protocol NavigationModePreview: UIView {
|
||||||
init(startAnimation: PassthroughSubject<Void, Never>)
|
init(startAnimation: PassthroughSubject<Void, Never>)
|
||||||
}
|
}
|
||||||
|
@ -118,6 +119,7 @@ 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 {
|
||||||
|
|
|
@ -18,13 +18,12 @@ class MyProfileViewController: ProfileViewController {
|
||||||
title = "My Profile"
|
title = "My Profile"
|
||||||
tabBarItem.image = UIImage(systemName: "person.fill")
|
tabBarItem.image = UIImage(systemName: "person.fill")
|
||||||
|
|
||||||
mastodonController.getOwnAccount { (result) in
|
Task {
|
||||||
guard case let .success(account) = result else { return }
|
guard let account = try? await mastodonController.getOwnAccount() else {
|
||||||
|
return
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.accountID = account.id
|
|
||||||
self.setAvatarTabBarImage(account: account)
|
|
||||||
}
|
}
|
||||||
|
self.accountID = account.id
|
||||||
|
self.setAvatarTabBarImage(account: account)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,7 @@ import Combine
|
||||||
|
|
||||||
class ProfileViewController: UIViewController, StateRestorableViewController {
|
class ProfileViewController: UIViewController, StateRestorableViewController {
|
||||||
|
|
||||||
weak var mastodonController: MastodonController!
|
let 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.
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
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)),
|
||||||
|
@ -15,6 +16,7 @@ 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
|
||||||
|
|
|
@ -15,6 +15,7 @@ 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)
|
||||||
|
@ -29,7 +30,7 @@ extension SearchResultsViewControllerDelegate {
|
||||||
|
|
||||||
class SearchResultsViewController: UIViewController, CollectionViewController {
|
class SearchResultsViewController: UIViewController, CollectionViewController {
|
||||||
|
|
||||||
weak var mastodonController: MastodonController!
|
let mastodonController: MastodonController
|
||||||
|
|
||||||
weak var exploreNavigationController: UINavigationController?
|
weak var exploreNavigationController: UINavigationController?
|
||||||
weak var delegate: SearchResultsViewControllerDelegate?
|
weak var delegate: SearchResultsViewControllerDelegate?
|
||||||
|
@ -372,7 +373,7 @@ extension SearchResultsViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SearchResultsViewController {
|
extension SearchResultsViewController {
|
||||||
enum Section: Hashable {
|
enum Section: Hashable, Sendable {
|
||||||
case tokenSuggestions(SearchOperatorType)
|
case tokenSuggestions(SearchOperatorType)
|
||||||
case loadingIndicator
|
case loadingIndicator
|
||||||
case accounts
|
case accounts
|
||||||
|
@ -394,7 +395,7 @@ extension SearchResultsViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
enum Item: Hashable {
|
enum Item: Hashable, Sendable {
|
||||||
case tokenSuggestion(String)
|
case tokenSuggestion(String)
|
||||||
case loadingIndicator
|
case loadingIndicator
|
||||||
case account(String)
|
case account(String)
|
||||||
|
|
|
@ -162,7 +162,7 @@ extension StatusEditHistoryViewController {
|
||||||
enum Section {
|
enum Section {
|
||||||
case edits
|
case edits
|
||||||
}
|
}
|
||||||
enum Item: Hashable, Equatable {
|
enum Item: Hashable, Equatable, Sendable {
|
||||||
case edit(StatusEdit, CollapseState, index: Int)
|
case edit(StatusEdit, CollapseState, index: Int)
|
||||||
case loadingIndicator
|
case loadingIndicator
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
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)
|
||||||
|
|
|
@ -13,6 +13,7 @@ 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?)
|
||||||
|
@ -31,7 +32,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
weak var delegate: TimelineViewControllerDelegate?
|
weak var delegate: TimelineViewControllerDelegate?
|
||||||
|
|
||||||
let timeline: Timeline
|
let timeline: Timeline
|
||||||
weak var mastodonController: MastodonController!
|
let mastodonController: MastodonController
|
||||||
private let filterer: Filterer
|
private let filterer: Filterer
|
||||||
|
|
||||||
var persistsState = false
|
var persistsState = false
|
||||||
|
@ -586,7 +587,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 dataSource.apply(snapshot, animatingDifferences: false)
|
await 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)")
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
protocol BackgroundableViewController {
|
protocol BackgroundableViewController {
|
||||||
func sceneDidEnterBackground()
|
func sceneDidEnterBackground()
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
protocol CollectionViewController: UIViewController {
|
protocol CollectionViewController: UIViewController {
|
||||||
var collectionView: UICollectionView! { get }
|
var collectionView: UICollectionView! { get }
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,12 +18,14 @@ 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)
|
||||||
}
|
}
|
||||||
|
@ -478,7 +480,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,
|
if let relationship = await relationship.value?.value,
|
||||||
let action = builder(relationship, mastodonController) {
|
let action = builder(relationship, mastodonController) {
|
||||||
elementHandler([action])
|
elementHandler([action])
|
||||||
} else {
|
} else {
|
||||||
|
@ -612,20 +614,25 @@ extension MenuActionProvider {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchRelationship(accountID: String, mastodonController: MastodonController) async -> RelationshipMO? {
|
private func fetchRelationship(accountID: String, mastodonController: MastodonController) async -> MainThreadBox<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
|
||||||
mastodonController.persistentContainer.addOrUpdate(relationship: r, in: mastodonController.persistentContainer.viewContext) { mo in
|
DispatchQueue.main.async {
|
||||||
continuation.resume(returning: mo)
|
mastodonController.persistentContainer.addOrUpdate(relationship: r, in: mastodonController.persistentContainer.viewContext) { mo in
|
||||||
|
continuation.resume(returning: MainThreadBox(value: 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
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
@objc protocol RefreshableViewController {
|
@objc protocol RefreshableViewController {
|
||||||
|
|
||||||
func refresh()
|
func refresh()
|
||||||
|
|
|
@ -339,6 +339,7 @@ private class SplitSecondaryNavigationController: EnhancedNavigationViewControll
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
protocol NestedResponderProvider {
|
protocol NestedResponderProvider {
|
||||||
var innerResponder: UIResponder? { get }
|
var innerResponder: UIResponder? { get }
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
protocol StateRestorableViewController: UIViewController {
|
protocol StateRestorableViewController: UIViewController {
|
||||||
func stateRestorationActivity() -> NSUserActivity?
|
func stateRestorationActivity() -> NSUserActivity?
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
protocol StatusBarTappableViewController: UIViewController {
|
protocol StatusBarTappableViewController: UIViewController {
|
||||||
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult
|
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
protocol TabBarScrollableViewController: UIViewController {
|
protocol TabBarScrollableViewController: UIViewController {
|
||||||
func tabBarScrollToTop()
|
func tabBarScrollToTop()
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
@objc protocol TabbedPageViewController {
|
@objc protocol TabbedPageViewController {
|
||||||
func selectNextPage()
|
func selectNextPage()
|
||||||
func selectPrevPage()
|
func selectPrevPage()
|
||||||
|
|
|
@ -54,6 +54,7 @@ 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)
|
||||||
|
|
|
@ -10,6 +10,7 @@ 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
|
||||||
|
|
||||||
|
@ -217,7 +218,7 @@ class TimelineLikeController<Item: Sendable> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum State: Equatable, CustomDebugStringConvertible {
|
enum State: Equatable, CustomDebugStringConvertible, Sendable {
|
||||||
case notLoadedInitial
|
case notLoadedInitial
|
||||||
case idle
|
case idle
|
||||||
case restoringInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
case restoringInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
||||||
|
@ -360,7 +361,7 @@ class TimelineLikeController<Item: Sendable> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class LoadAttemptToken: Equatable {
|
final class LoadAttemptToken: Equatable, Sendable {
|
||||||
static func ==(lhs: LoadAttemptToken, rhs: LoadAttemptToken) -> Bool {
|
static func ==(lhs: LoadAttemptToken, rhs: LoadAttemptToken) -> Bool {
|
||||||
return lhs === rhs
|
return lhs === rhs
|
||||||
}
|
}
|
||||||
|
|
|
@ -191,6 +191,7 @@ 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 {
|
||||||
|
|
|
@ -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 }
|
||||||
self.avatarRequest = nil
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
self.avatarRequest = nil
|
||||||
self.avatarImageView.image = image
|
self.avatarImageView.image = image
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ 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)
|
||||||
|
@ -29,7 +30,7 @@ class AttachmentView: GIFImageView {
|
||||||
var attachment: Attachment!
|
var attachment: Attachment!
|
||||||
var index: Int!
|
var index: Int!
|
||||||
|
|
||||||
private var attachmentRequest: ImageCache.Request?
|
private var loadAttachmentTask: Task<Void, Never>?
|
||||||
private var source: Source?
|
private var source: Source?
|
||||||
|
|
||||||
private var autoplayGifs: Bool {
|
private var autoplayGifs: Bool {
|
||||||
|
@ -44,11 +45,13 @@ class AttachmentView: GIFImageView {
|
||||||
|
|
||||||
self.attachment = attachment
|
self.attachment = attachment
|
||||||
self.index = index
|
self.index = index
|
||||||
loadAttachment()
|
self.loadAttachmentTask = Task {
|
||||||
|
await self.loadAttachment()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
attachmentRequest?.cancel()
|
loadAttachmentTask?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder aDecoder: NSCoder) {
|
required init?(coder aDecoder: NSCoder) {
|
||||||
|
@ -75,7 +78,9 @@ class AttachmentView: GIFImageView {
|
||||||
gifPlaybackModeChanged()
|
gifPlaybackModeChanged()
|
||||||
|
|
||||||
if isGrayscale != Preferences.shared.grayscaleImages {
|
if isGrayscale != Preferences.shared.grayscaleImages {
|
||||||
self.displayImage()
|
Task {
|
||||||
|
await displayImage()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if getBadges().isEmpty != Preferences.shared.showAttachmentBadges {
|
if getBadges().isEmpty != Preferences.shared.showAttachmentBadges {
|
||||||
|
@ -106,34 +111,34 @@ class AttachmentView: GIFImageView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadAttachment() {
|
private func loadAttachment() async {
|
||||||
|
let blurHashTask: Task<Void, any Error>?
|
||||||
if let hash = attachment.blurHash {
|
if let hash = attachment.blurHash {
|
||||||
AttachmentView.queue.async { [weak self] in
|
blurHashTask = Task {
|
||||||
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
|
self.image = preview
|
||||||
guard let self = self, self.image == nil else { return }
|
|
||||||
self.image = preview
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
blurHashTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
createBadgesView(getBadges())
|
createBadgesView(getBadges())
|
||||||
|
|
||||||
switch attachment.kind {
|
switch attachment.kind {
|
||||||
case .image:
|
case .image:
|
||||||
loadImage()
|
await loadImage()
|
||||||
case .video:
|
case .video:
|
||||||
loadVideo()
|
await loadVideo()
|
||||||
case .audio:
|
case .audio:
|
||||||
loadAudio()
|
loadAudio()
|
||||||
case .gifv:
|
case .gifv:
|
||||||
|
@ -141,6 +146,8 @@ class AttachmentView: GIFImageView {
|
||||||
case .unknown:
|
case .unknown:
|
||||||
createUnknownLabel()
|
createUnknownLabel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
blurHashTask?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getBadges() -> Badges {
|
private func getBadges() -> Badges {
|
||||||
|
@ -181,66 +188,27 @@ class AttachmentView: GIFImageView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadImage() {
|
private func loadImage() async {
|
||||||
let attachmentURL = attachment.url
|
let (data, image) = await ImageCache.attachments.get(attachment.url)
|
||||||
attachmentRequest = ImageCache.attachments.get(attachmentURL) { [weak self] (data, image) in
|
guard !Task.isCancelled else { return }
|
||||||
guard let self = self,
|
|
||||||
self.attachment.url == attachmentURL else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
if attachment.url.pathExtension == "gif",
|
||||||
self.attachmentRequest = nil
|
let data {
|
||||||
|
source = .gifData(attachment.url, data, image)
|
||||||
if attachmentURL.pathExtension == "gif",
|
if autoplayGifs {
|
||||||
let data {
|
let controller = GIFController(gifData: data)
|
||||||
self.source = .gifData(attachmentURL, data, image)
|
controller.attach(to: self)
|
||||||
if self.autoplayGifs {
|
controller.startAnimating()
|
||||||
let controller = GIFController(gifData: data)
|
} else {
|
||||||
controller.attach(to: self)
|
await displayImage()
|
||||||
controller.startAnimating()
|
|
||||||
} else {
|
|
||||||
self.displayImage()
|
|
||||||
}
|
|
||||||
} else if let image {
|
|
||||||
self.source = .image(attachmentURL, image)
|
|
||||||
self.displayImage()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else if let image {
|
||||||
|
source = .image(attachment.url, image)
|
||||||
|
await displayImage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadVideo() {
|
private func loadVideo() async {
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
|
@ -250,9 +218,40 @@ 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)
|
||||||
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadAudio() {
|
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"))
|
||||||
|
@ -273,23 +272,8 @@ class AttachmentView: GIFImageView {
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadGifv() {
|
private func loadGifv() {
|
||||||
let attachmentURL = self.attachment.url
|
let asset = AVURLAsset(url: 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
|
||||||
|
@ -305,7 +289,7 @@ class AttachmentView: GIFImageView {
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func createUnknownLabel() {
|
private func createUnknownLabel() {
|
||||||
backgroundColor = .appSecondaryBackground
|
backgroundColor = .appSecondaryBackground
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.text = "Unknown Attachment Type"
|
label.text = "Unknown Attachment Type"
|
||||||
|
@ -324,8 +308,7 @@ class AttachmentView: GIFImageView {
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
private func displayImage() async {
|
||||||
private func displayImage() {
|
|
||||||
isGrayscale = Preferences.shared.grayscaleImages
|
isGrayscale = Preferences.shared.grayscaleImages
|
||||||
|
|
||||||
switch source {
|
switch source {
|
||||||
|
@ -334,12 +317,7 @@ class AttachmentView: GIFImageView {
|
||||||
|
|
||||||
case let .image(url, sourceImage):
|
case let .image(url, sourceImage):
|
||||||
if isGrayscale {
|
if isGrayscale {
|
||||||
ImageGrayscalifier.queue.async { [weak self] in
|
self.image = await ImageGrayscalifier.convert(url: url, image: sourceImage)
|
||||||
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
|
||||||
}
|
}
|
||||||
|
@ -347,26 +325,16 @@ class AttachmentView: GIFImageView {
|
||||||
case let .gifData(url, _, sourceImage):
|
case let .gifData(url, _, sourceImage):
|
||||||
if isGrayscale,
|
if isGrayscale,
|
||||||
let sourceImage {
|
let sourceImage {
|
||||||
ImageGrayscalifier.queue.async { [weak self] in
|
self.image = await ImageGrayscalifier.convert(url: url, image: sourceImage)
|
||||||
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 {
|
||||||
ImageGrayscalifier.queue.async { [weak self] in
|
self.image = await ImageGrayscalifier.convert(url: url, cgImage: cgImage)
|
||||||
let grayscale = ImageGrayscalifier.convert(url: url, cgImage: cgImage)
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.image = grayscale
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
image = UIImage(cgImage: cgImage)
|
self.image = UIImage(cgImage: cgImage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,12 +11,8 @@ 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 }
|
||||||
|
@ -47,10 +43,15 @@ 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)
|
||||||
var scale: CGFloat = 1.4
|
let scale = 1.4 * screenScale
|
||||||
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
|
||||||
}
|
}
|
||||||
|
@ -80,7 +81,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: imageScale, orientation: .up)
|
emojiImages[emoji.shortcode] = UIImage(cgImage: cgImage, scale: screenScale, orientation: .up)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// otherwise, perform the network request
|
// otherwise, perform the network request
|
||||||
|
@ -93,7 +94,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: imageScale, orientation: .up),
|
case let rescaled = UIImage(cgImage: thumbnail, scale: screenScale, 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
|
||||||
|
|
|
@ -90,8 +90,7 @@ class CachedImageView: UIImageView {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
// TODO: check that this isn't on the main thread
|
guard let transformedImage = await ImageGrayscalifier.convertIfNecessary(url: url, image: image) else {
|
||||||
guard let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
|
|
@ -93,7 +93,9 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||||
updateLinkUnderlineStyle()
|
updateLinkUnderlineStyle()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateLinkUnderlineStyle(preference: Bool = Preferences.shared.underlineTextLinks) {
|
@MainActor
|
||||||
|
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 {
|
||||||
|
|
|
@ -19,8 +19,11 @@ class InstanceTableViewCell: UITableViewCell {
|
||||||
var instance: InstanceV1?
|
var instance: InstanceV1?
|
||||||
var selectorInstance: InstanceSelector.Instance?
|
var selectorInstance: InstanceSelector.Instance?
|
||||||
|
|
||||||
var thumbnailURL: URL?
|
private var thumbnailTask: Task<Void, Never>?
|
||||||
var thumbnailRequest: ImageCache.Request?
|
|
||||||
|
deinit {
|
||||||
|
thumbnailTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
override func awakeFromNib() {
|
override func awakeFromNib() {
|
||||||
super.awakeFromNib()
|
super.awakeFromNib()
|
||||||
|
@ -68,20 +71,19 @@ class InstanceTableViewCell: UITableViewCell {
|
||||||
|
|
||||||
private func updateThumbnail(url: URL) {
|
private func updateThumbnail(url: URL) {
|
||||||
thumbnailImageView.image = nil
|
thumbnailImageView.image = nil
|
||||||
thumbnailURL = url
|
thumbnailTask = Task {
|
||||||
thumbnailRequest = ImageCache.attachments.get(url) { [weak self] (_, image) in
|
guard let image = await ImageCache.attachments.get(url).1,
|
||||||
guard let self = self, self.thumbnailURL == url, let image = image else { return }
|
!Task.isCancelled else {
|
||||||
self.thumbnailRequest = nil
|
return
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.thumbnailImageView.image = image
|
|
||||||
}
|
}
|
||||||
|
thumbnailImageView.image = image
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func prepareForReuse() {
|
override func prepareForReuse() {
|
||||||
super.prepareForReuse()
|
super.prepareForReuse()
|
||||||
|
|
||||||
thumbnailRequest?.cancel()
|
thumbnailTask?.cancel()
|
||||||
instance = nil
|
instance = nil
|
||||||
selectorInstance = nil
|
selectorInstance = nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = { [weak self] in
|
let recombine: @MainActor @Sendable () -> Void = { [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)
|
||||||
|
|
|
@ -53,7 +53,9 @@ class ProfileFieldsView: UIView {
|
||||||
|
|
||||||
private func commonInit() {
|
private func commonInit() {
|
||||||
boundsObservation = observe(\.bounds, changeHandler: { [unowned self] _, _ in
|
boundsObservation = observe(\.bounds, changeHandler: { [unowned self] _, _ in
|
||||||
self.setNeedsUpdateConstraints()
|
MainActor.runUnsafely {
|
||||||
|
self.setNeedsUpdateConstraints()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
#if os(visionOS)
|
#if os(visionOS)
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
@ -41,8 +42,7 @@ class ProfileHeaderView: UIView {
|
||||||
|
|
||||||
var accountID: String!
|
var accountID: String!
|
||||||
|
|
||||||
private var avatarRequest: ImageCache.Request?
|
private var imagesTask: Task<Void, Never>?
|
||||||
private var headerRequest: ImageCache.Request?
|
|
||||||
|
|
||||||
private var isGrayscale = false
|
private var isGrayscale = false
|
||||||
private var followButtonMode = FollowButtonMode.follow {
|
private var followButtonMode = FollowButtonMode.follow {
|
||||||
|
@ -55,8 +55,7 @@ class ProfileHeaderView: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
avatarRequest?.cancel()
|
imagesTask?.cancel()
|
||||||
headerRequest?.cancel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func awakeFromNib() {
|
override func awakeFromNib() {
|
||||||
|
@ -133,7 +132,12 @@ class ProfileHeaderView: UIView {
|
||||||
usernameLabel.text = "@\(account.acct)"
|
usernameLabel.text = "@\(account.acct)"
|
||||||
lockImageView.isHidden = !account.locked
|
lockImageView.isHidden = !account.locked
|
||||||
|
|
||||||
updateImages(account: account)
|
imagesTask?.cancel()
|
||||||
|
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) ?? [])
|
||||||
|
|
||||||
|
@ -285,50 +289,41 @@ class ProfileHeaderView: UIView {
|
||||||
displayNameLabel.updateForAccountDisplayName(account: account)
|
displayNameLabel.updateForAccountDisplayName(account: account)
|
||||||
|
|
||||||
if isGrayscale != Preferences.shared.grayscaleImages {
|
if isGrayscale != Preferences.shared.grayscaleImages {
|
||||||
updateImages(account: account)
|
isGrayscale = Preferences.shared.grayscaleImages
|
||||||
|
imagesTask?.cancel()
|
||||||
|
let avatar = account.avatar
|
||||||
|
let header = account.header
|
||||||
|
imagesTask = Task {
|
||||||
|
await updateImages(avatar: avatar, header: header)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateImages(account: AccountMO) {
|
private nonisolated func updateImages(avatar: URL?, header: URL?) async {
|
||||||
isGrayscale = Preferences.shared.grayscaleImages
|
await withTaskGroup(of: Void.self) { group in
|
||||||
|
group.addTask {
|
||||||
let accountID = account.id
|
guard let avatar,
|
||||||
if let avatarURL = account.avatar {
|
let image = await ImageCache.avatars.get(avatar, loadOriginal: true).1,
|
||||||
// always load original for avatars, because ImageCache.avatars stores them scaled-down in memory
|
let transformedImage = await ImageGrayscalifier.convertIfNecessary(url: avatar, image: image),
|
||||||
avatarRequest = ImageCache.avatars.get(avatarURL, loadOriginal: true) { [weak self] (_, image) in
|
!Task.isCancelled else {
|
||||||
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 {
|
||||||
if let header = account.header {
|
guard let header,
|
||||||
headerRequest = ImageCache.headers.get(header) { [weak self] (_, image) in
|
let image = await ImageCache.avatars.get(header, loadOriginal: true).1,
|
||||||
guard let self = self,
|
let transformedImage = await ImageGrayscalifier.convertIfNecessary(url: header, image: image),
|
||||||
let image = image,
|
!Task.isCancelled else {
|
||||||
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -179,13 +179,16 @@ extension StatusContentContainer {
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension UIView {
|
private extension UIView {
|
||||||
func observeIsHidden(_ f: @escaping () -> Void) -> NSKeyValueObservation {
|
func observeIsHidden(_ f: @escaping @Sendable @MainActor () -> Void) -> NSKeyValueObservation {
|
||||||
self.observe(\.isHidden) { _, _ in
|
self.observe(\.isHidden) { _, _ in
|
||||||
f()
|
MainActor.runUnsafely {
|
||||||
|
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
|
||||||
|
|
|
@ -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: ((ToastView) -> Void)?
|
var action: (@MainActor (ToastView) -> Void)?
|
||||||
var longPressAction: ((ToastView) -> Void)?
|
var longPressAction: (@MainActor (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: ((ToastView) -> Void)?) {
|
init(from error: Error, with title: String, in viewController: TuskerNavigationDelegate, retryAction: (@MainActor (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: @escaping @MainActor (ToastView) async -> Void) {
|
init(from error: Error, with title: String, in viewController: TuskerNavigationDelegate, retryAction: @Sendable @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)
|
||||||
|
|
Loading…
Reference in New Issue