From 5e609aa40db43185bc62e8d35696dc55eeb2c14b Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 4 Dec 2023 17:55:03 -0500 Subject: [PATCH] V2 instance API, add translation to InstanceFeatures --- .../InstanceFeatures/InstanceFeatures.swift | 4 +- .../InstanceFeatures/InstanceInfo.swift | 27 +++- .../Pachyderm/Sources/Pachyderm/Client.swift | 8 +- .../{Instance.swift => InstanceV1.swift} | 20 +-- .../Sources/Pachyderm/Model/InstanceV2.swift | 125 ++++++++++++++++++ ShareExtension/ShareMastodonContext.swift | 4 +- Tusker/API/MastodonController.swift | 38 ++++-- Tusker/CoreData/ActiveInstance.swift | 19 +-- .../Tusker.xcdatamodel/contents | 1 + .../InstanceSelectorTableViewController.swift | 4 +- .../Instance Cell/InstanceTableViewCell.swift | 4 +- 11 files changed, 210 insertions(+), 44 deletions(-) rename Packages/Pachyderm/Sources/Pachyderm/Model/{Instance.swift => InstanceV1.swift} (94%) create mode 100644 Packages/Pachyderm/Sources/Pachyderm/Model/InstanceV2.swift diff --git a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift index cfd35891..5324ee1c 100644 --- a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift +++ b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift @@ -21,7 +21,8 @@ public class InstanceFeatures: ObservableObject { @Published public private(set) var charsReservedPerURL = 23 @Published public private(set) var maxPollOptionChars: Int? @Published public private(set) var maxPollOptionsCount: Int? - @Published public private(set) var mediaAttachmentsConfiguration: Instance.MediaAttachmentsConfiguration? + @Published public private(set) var mediaAttachmentsConfiguration: InstanceV1.MediaAttachmentsConfiguration? + @Published public private(set) var translation: Bool = false public var localOnlyPosts: Bool { switch instanceType { @@ -240,6 +241,7 @@ public class InstanceFeatures: ObservableObject { maxPollOptionsCount = pollsConfig.maxOptions } mediaAttachmentsConfiguration = instance.configuration?.mediaAttachments + translation = instance.translation _featuresUpdated.send() } diff --git a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceInfo.swift b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceInfo.swift index d4a5e984..5685867b 100644 --- a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceInfo.swift +++ b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceInfo.swift @@ -9,26 +9,39 @@ import Foundation import Pachyderm public struct InstanceInfo { - public let version: String - public let maxStatusCharacters: Int? - public let configuration: Instance.Configuration? - public let pollsConfiguration: Instance.PollsConfiguration? + public var version: String + public var maxStatusCharacters: Int? + public var configuration: InstanceV1.Configuration? + public var pollsConfiguration: InstanceV1.PollsConfiguration? + public var translation: Bool - public init(version: String, maxStatusCharacters: Int?, configuration: Instance.Configuration?, pollsConfiguration: Instance.PollsConfiguration?) { + public init( + version: String, + maxStatusCharacters: Int?, + configuration: InstanceV1.Configuration?, + pollsConfiguration: InstanceV1.PollsConfiguration?, + translation: Bool + ) { self.version = version self.maxStatusCharacters = maxStatusCharacters self.configuration = configuration self.pollsConfiguration = pollsConfiguration + self.translation = translation } } extension InstanceInfo { - public init(instance: Instance) { + public init(v1 instance: InstanceV1) { self.init( version: instance.version, maxStatusCharacters: instance.maxStatusCharacters, configuration: instance.configuration, - pollsConfiguration: instance.pollsConfiguration + pollsConfiguration: instance.pollsConfiguration, + translation: false ) } + + public mutating func update(v2: InstanceV2) { + translation = v2.configuration.translation.enabled + } } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Client.swift b/Packages/Pachyderm/Sources/Pachyderm/Client.swift index f4bed14c..c94473df 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Client.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Client.swift @@ -231,8 +231,12 @@ public class Client { return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts) } - public static func getInstance() -> Request { - return Request(method: .get, path: "/api/v1/instance") + public static func getInstanceV1() -> Request { + return Request(method: .get, path: "/api/v1/instance") + } + + public static func getInstanceV2() -> Request { + return Request(method: .get, path: "/api/v2/instance") } public static func getCustomEmoji() -> Request<[Emoji]> { diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Instance.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/InstanceV1.swift similarity index 94% rename from Packages/Pachyderm/Sources/Pachyderm/Model/Instance.swift rename to Packages/Pachyderm/Sources/Pachyderm/Model/InstanceV1.swift index b90ca413..57b380bc 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Instance.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/InstanceV1.swift @@ -1,5 +1,5 @@ // -// Instance.swift +// InstanceV1.swift // Pachyderm // // Created by Shadowfacts on 9/9/18. @@ -8,7 +8,7 @@ import Foundation -public struct Instance: Decodable, Sendable { +public struct InstanceV1: Decodable, Sendable { public let uri: String public let title: String public let description: String @@ -92,7 +92,7 @@ public struct Instance: Decodable, Sendable { } } -extension Instance { +extension InstanceV1 { public struct Stats: Decodable, Sendable { public let domainCount: Int? public let statusCount: Int? @@ -106,7 +106,7 @@ extension Instance { } } -extension Instance { +extension InstanceV1 { public struct Configuration: Codable, Sendable { public let statuses: StatusesConfiguration public let mediaAttachments: MediaAttachmentsConfiguration @@ -121,7 +121,8 @@ extension Instance { } } -extension Instance { +extension InstanceV1 { + // note: also used by InstanceV2 public struct StatusesConfiguration: Codable, Sendable { public let maxCharacters: Int public let maxMediaAttachments: Int @@ -135,7 +136,8 @@ extension Instance { } } -extension Instance { +extension InstanceV1 { + // note: also used by InstanceV2 public struct MediaAttachmentsConfiguration: Codable, Sendable { public let supportedMIMETypes: [String] public let imageSizeLimit: Int @@ -155,7 +157,8 @@ extension Instance { } } -extension Instance { +extension InstanceV1 { + // note: also used by InstanceV2 public struct PollsConfiguration: Codable, Sendable { public let maxOptions: Int public let maxCharactersPerOption: Int @@ -171,7 +174,8 @@ extension Instance { } } -extension Instance { +extension InstanceV1 { + // note: also used by InstanceV2 public struct Rule: Decodable, Identifiable, Sendable { public let id: String public let text: String diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/InstanceV2.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/InstanceV2.swift new file mode 100644 index 00000000..b1ebe70c --- /dev/null +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/InstanceV2.swift @@ -0,0 +1,125 @@ +// +// InstanceV2.swift +// Pachyderm +// +// Created by Shadowfacts on 12/4/23. +// + +import Foundation + +public struct InstanceV2: Decodable, Sendable { + public let domain: String + public let title: String + public let version: String + public let sourceURL: String + public let description: String + public let usage: Usage + public let thumbnail: Thumbnail + public let languages: [String] + public let configuration: Configuration + public let registrations: Registrations + public let contact: Contact + public let rules: [InstanceV1.Rule] + + private enum CodingKeys: String, CodingKey { + case domain + case title + case version + case sourceURL = "source_url" + case description + case usage + case thumbnail + case languages + case configuration + case registrations + case contact + case rules + } +} + +extension InstanceV2 { + public struct Usage: Decodable, Sendable { + public let users: Users + } + public struct Users: Decodable, Sendable { + public let activeMonth: Int + private enum CodingKeys: String, CodingKey { + case activeMonth = "active_month" + } + } +} + +extension InstanceV2 { + public struct Thumbnail: Decodable, Sendable { + public let url: String + public let blurhash: String? + public let versions: ThumbnailVersions + } + + public struct ThumbnailVersions: Decodable, Sendable { + public let oneX: String? + public let twoX: String? + private enum CodingKeys: String, CodingKey { + case oneX = "@1x" + case twoX = "@2x" + } + } +} + +extension InstanceV2 { + public struct Configuration: Decodable, Sendable { + public let urls: URLs + public let accounts: Accounts + public let statuses: InstanceV1.StatusesConfiguration + public let mediaAttachments: InstanceV1.MediaAttachmentsConfiguration + public let polls: InstanceV1.PollsConfiguration + public let translation: Translation + + private enum CodingKeys: String, CodingKey { + case urls + case accounts + case statuses + case mediaAttachments = "media_attachments" + case polls + case translation + } + } + + public struct URLs: Decodable, Sendable { + // the docs incorrectly say the key for this is "streaming_api" + public let streaming: String + } + + public struct Accounts: Decodable, Sendable { + public let maxFeaturedTags: Int + + private enum CodingKeys: String, CodingKey { + case maxFeaturedTags = "max_featured_tags" + } + } + + public struct Translation: Decodable, Sendable { + public let enabled: Bool + } +} + +extension InstanceV2 { + public struct Registrations: Decodable, Sendable { + public let enabled: Bool + public let approvalRequired: Bool + public let message: String? + + private enum CodingKeys: String, CodingKey { + case enabled + case approvalRequired = "approval_required" + case message + } + } +} + +extension InstanceV2 { + public struct Contact: Decodable, Sendable { + public let email: String + public let account: Account + } +} diff --git a/ShareExtension/ShareMastodonContext.swift b/ShareExtension/ShareMastodonContext.swift index f9914a12..f8b80c1a 100644 --- a/ShareExtension/ShareMastodonContext.swift +++ b/ShareExtension/ShareMastodonContext.swift @@ -28,7 +28,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject { self.instanceFeatures = InstanceFeatures() Task { @MainActor in - async let instance = try? await run(Client.getInstance()).0 + async let instance = try? await run(Client.getInstanceV1()).0 async let nodeInfo: NodeInfo? = await withCheckedContinuation({ continuation in self.client.nodeInfo { response in switch response { @@ -40,7 +40,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject { } }) guard let instance = await instance else { return } - self.instanceFeatures.update(instance: InstanceInfo(instance: instance), nodeInfo: await nodeInfo) + self.instanceFeatures.update(instance: InstanceInfo(v1: instance), nodeInfo: await nodeInfo) } Task { @MainActor in diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index d80bbcfa..f0028fab 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -53,7 +53,8 @@ class MastodonController: ObservableObject { let instanceFeatures = InstanceFeatures() @Published private(set) var account: AccountMO? - @Published private(set) var instance: Instance? + @Published private(set) var instance: InstanceV1? + @Published private var instanceV2: InstanceV2? @Published private(set) var instanceInfo: InstanceInfo! @Published private(set) var nodeInfo: NodeInfo! @Published private(set) var lists: [List] = [] @@ -63,7 +64,7 @@ class MastodonController: ObservableObject { private var cancellables = Set() - private var pendingOwnInstanceRequestCallbacks = [(Result) -> Void]() + private var pendingOwnInstanceRequestCallbacks = [(Result) -> Void]() private var ownInstanceRequest: URLSessionTask? var loggedIn: Bool { @@ -107,9 +108,14 @@ class MastodonController: ObservableObject { $instance .compactMap { $0 } - .sink { [unowned self] in - self.updateActiveInstance(from: $0) - self.instanceInfo = InstanceInfo(instance: $0) + .combineLatest($instanceV2) + .sink {[unowned self] (instance, v2) in + var info = InstanceInfo(v1: instance) + if let v2 { + info.update(v2: v2) + } + self.instanceInfo = info + self.updateActiveInstance(from: info) } .store(in: &cancellables) @@ -217,6 +223,10 @@ class MastodonController: ObservableObject { _ = try await (ownAccount, ownInstance) + if instanceFeatures.hasMastodonVersion(4, 0, 0) { + async let _ = try? getOwnInstanceV2() + } + loadLists() _ = await loadFilters() await loadServerPreferences() @@ -277,7 +287,7 @@ class MastodonController: ObservableObject { } } - func getOwnInstance(completion: ((Instance) -> Void)? = nil) { + func getOwnInstance(completion: ((InstanceV1) -> Void)? = nil) { getOwnInstanceInternal(retryAttempt: 0) { if case let .success(instance) = $0 { completion?(instance) @@ -286,7 +296,7 @@ class MastodonController: ObservableObject { } @MainActor - func getOwnInstance() async throws -> Instance { + func getOwnInstance() async throws -> InstanceV1 { return try await withCheckedThrowingContinuation({ continuation in getOwnInstanceInternal(retryAttempt: 0) { result in continuation.resume(with: result) @@ -294,7 +304,7 @@ class MastodonController: ObservableObject { }) } - private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result) -> Void)?) { + private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result) -> Void)?) { // this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks assert(Thread.isMainThread) @@ -306,7 +316,7 @@ class MastodonController: ObservableObject { } if ownInstanceRequest == nil { - let request = Client.getInstance() + let request = Client.getInstanceV1() ownInstanceRequest = run(request) { (response) in switch response { case .failure(let error): @@ -361,6 +371,10 @@ class MastodonController: ObservableObject { } } + private func getOwnInstanceV2() async throws { + self.instanceV2 = try await client.run(Client.getInstanceV2()).0 + } + // MainActor because the accountPreferences instance is bound to the view context @MainActor private func loadServerPreferences() async { @@ -376,13 +390,13 @@ class MastodonController: ObservableObject { } } - private func updateActiveInstance(from instance: Instance) { + private func updateActiveInstance(from info: InstanceInfo) { persistentContainer.performBackgroundTask { context in if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first { - existing.update(from: instance) + existing.update(from: info) } else { let new = ActiveInstance(context: context) - new.update(from: instance) + new.update(from: info) } if context.hasChanges { try? context.save() diff --git a/Tusker/CoreData/ActiveInstance.swift b/Tusker/CoreData/ActiveInstance.swift index 1a2a36b9..45f5a453 100644 --- a/Tusker/CoreData/ActiveInstance.swift +++ b/Tusker/CoreData/ActiveInstance.swift @@ -22,18 +22,20 @@ public final class ActiveInstance: NSManagedObject { @NSManaged public var maxStatusCharacters: Int @NSManaged private var configurationData: Data? @NSManaged private var pollsConfigurationData: Data? + @NSManaged public var translation: Bool @LazilyDecoding(from: \ActiveInstance.configurationData, fallback: nil) - public var configuration: Instance.Configuration? + public var configuration: InstanceV1.Configuration? @LazilyDecoding(from: \ActiveInstance.pollsConfigurationData, fallback: nil) - public var pollsConfiguration: Instance.PollsConfiguration? + public var pollsConfiguration: InstanceV1.PollsConfiguration? - func update(from instance: Instance) { - self.version = instance.version - self.maxStatusCharacters = instance.maxStatusCharacters ?? 500 - self.configuration = instance.configuration - self.pollsConfiguration = instance.pollsConfiguration + func update(from info: InstanceInfo) { + self.version = info.version + self.maxStatusCharacters = info.maxStatusCharacters ?? 500 + self.configuration = info.configuration + self.pollsConfiguration = info.pollsConfiguration + self.translation = info.translation } } @@ -43,7 +45,8 @@ extension InstanceInfo { version: activeInstance.version, maxStatusCharacters: activeInstance.maxStatusCharacters, configuration: activeInstance.configuration, - pollsConfiguration: activeInstance.pollsConfiguration + pollsConfiguration: activeInstance.pollsConfiguration, + translation: activeInstance.translation ) } } diff --git a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents index ae05395c..23c42c0d 100644 --- a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents +++ b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents @@ -41,6 +41,7 @@ + diff --git a/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift b/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift index 60aed0c1..30f06a82 100644 --- a/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift +++ b/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift @@ -162,7 +162,7 @@ class InstanceSelectorTableViewController: UITableViewController { } let client = Client(baseURL: url, session: .appDefault) - let request = Client.getInstance() + let request = Client.getInstanceV1() client.run(request) { (response) in var snapshot = self.dataSource.snapshot() if snapshot.indexOfSection(.selected) != nil { @@ -309,7 +309,7 @@ extension InstanceSelectorTableViewController { case recommendedInstances } enum Item: Equatable, Hashable { - case selected(URL, Instance) + case selected(URL, InstanceV1) case recommended(InstanceSelector.Instance) static func ==(lhs: Item, rhs: Item) -> Bool { diff --git a/Tusker/Views/Instance Cell/InstanceTableViewCell.swift b/Tusker/Views/Instance Cell/InstanceTableViewCell.swift index d398d655..37afd99a 100644 --- a/Tusker/Views/Instance Cell/InstanceTableViewCell.swift +++ b/Tusker/Views/Instance Cell/InstanceTableViewCell.swift @@ -16,7 +16,7 @@ class InstanceTableViewCell: UITableViewCell { @IBOutlet weak var adultLabel: UILabel! @IBOutlet weak var descriptionTextView: ContentTextView! - var instance: Instance? + var instance: InstanceV1? var selectorInstance: InstanceSelector.Instance? var thumbnailURL: URL? @@ -53,7 +53,7 @@ class InstanceTableViewCell: UITableViewCell { updateThumbnail(url: instance.proxiedThumbnailURL) } - func updateUI(instance: Instance) { + func updateUI(instance: InstanceV1) { self.instance = instance self.selectorInstance = nil