V2 instance API, add translation to InstanceFeatures

This commit is contained in:
Shadowfacts 2023-12-04 17:55:03 -05:00
parent 158940f8e6
commit 5e609aa40d
11 changed files with 210 additions and 44 deletions

View File

@ -21,7 +21,8 @@ public class InstanceFeatures: ObservableObject {
@Published public private(set) var charsReservedPerURL = 23 @Published public private(set) var charsReservedPerURL = 23
@Published public private(set) var maxPollOptionChars: Int? @Published public private(set) var maxPollOptionChars: Int?
@Published public private(set) var maxPollOptionsCount: 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 { public var localOnlyPosts: Bool {
switch instanceType { switch instanceType {
@ -240,6 +241,7 @@ public class InstanceFeatures: ObservableObject {
maxPollOptionsCount = pollsConfig.maxOptions maxPollOptionsCount = pollsConfig.maxOptions
} }
mediaAttachmentsConfiguration = instance.configuration?.mediaAttachments mediaAttachmentsConfiguration = instance.configuration?.mediaAttachments
translation = instance.translation
_featuresUpdated.send() _featuresUpdated.send()
} }

View File

@ -9,26 +9,39 @@ import Foundation
import Pachyderm import Pachyderm
public struct InstanceInfo { public struct InstanceInfo {
public let version: String public var version: String
public let maxStatusCharacters: Int? public var maxStatusCharacters: Int?
public let configuration: Instance.Configuration? public var configuration: InstanceV1.Configuration?
public let pollsConfiguration: Instance.PollsConfiguration? 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.version = version
self.maxStatusCharacters = maxStatusCharacters self.maxStatusCharacters = maxStatusCharacters
self.configuration = configuration self.configuration = configuration
self.pollsConfiguration = pollsConfiguration self.pollsConfiguration = pollsConfiguration
self.translation = translation
} }
} }
extension InstanceInfo { extension InstanceInfo {
public init(instance: Instance) { public init(v1 instance: InstanceV1) {
self.init( self.init(
version: instance.version, version: instance.version,
maxStatusCharacters: instance.maxStatusCharacters, maxStatusCharacters: instance.maxStatusCharacters,
configuration: instance.configuration, configuration: instance.configuration,
pollsConfiguration: instance.pollsConfiguration pollsConfiguration: instance.pollsConfiguration,
translation: false
) )
} }
public mutating func update(v2: InstanceV2) {
translation = v2.configuration.translation.enabled
}
} }

View File

@ -231,8 +231,12 @@ public class Client {
return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts) return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
} }
public static func getInstance() -> Request<Instance> { public static func getInstanceV1() -> Request<InstanceV1> {
return Request<Instance>(method: .get, path: "/api/v1/instance") return Request<InstanceV1>(method: .get, path: "/api/v1/instance")
}
public static func getInstanceV2() -> Request<InstanceV2> {
return Request<InstanceV2>(method: .get, path: "/api/v2/instance")
} }
public static func getCustomEmoji() -> Request<[Emoji]> { public static func getCustomEmoji() -> Request<[Emoji]> {

View File

@ -1,5 +1,5 @@
// //
// Instance.swift // InstanceV1.swift
// Pachyderm // Pachyderm
// //
// Created by Shadowfacts on 9/9/18. // Created by Shadowfacts on 9/9/18.
@ -8,7 +8,7 @@
import Foundation import Foundation
public struct Instance: Decodable, Sendable { public struct InstanceV1: Decodable, Sendable {
public let uri: String public let uri: String
public let title: String public let title: String
public let description: String public let description: String
@ -92,7 +92,7 @@ public struct Instance: Decodable, Sendable {
} }
} }
extension Instance { extension InstanceV1 {
public struct Stats: Decodable, Sendable { public struct Stats: Decodable, Sendable {
public let domainCount: Int? public let domainCount: Int?
public let statusCount: Int? public let statusCount: Int?
@ -106,7 +106,7 @@ extension Instance {
} }
} }
extension Instance { extension InstanceV1 {
public struct Configuration: Codable, Sendable { public struct Configuration: Codable, Sendable {
public let statuses: StatusesConfiguration public let statuses: StatusesConfiguration
public let mediaAttachments: MediaAttachmentsConfiguration 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 struct StatusesConfiguration: Codable, Sendable {
public let maxCharacters: Int public let maxCharacters: Int
public let maxMediaAttachments: 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 struct MediaAttachmentsConfiguration: Codable, Sendable {
public let supportedMIMETypes: [String] public let supportedMIMETypes: [String]
public let imageSizeLimit: Int 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 struct PollsConfiguration: Codable, Sendable {
public let maxOptions: Int public let maxOptions: Int
public let maxCharactersPerOption: 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 struct Rule: Decodable, Identifiable, Sendable {
public let id: String public let id: String
public let text: String public let text: String

View File

@ -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
}
}

View File

@ -28,7 +28,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
self.instanceFeatures = InstanceFeatures() self.instanceFeatures = InstanceFeatures()
Task { @MainActor in 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 async let nodeInfo: NodeInfo? = await withCheckedContinuation({ continuation in
self.client.nodeInfo { response in self.client.nodeInfo { response in
switch response { switch response {
@ -40,7 +40,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
} }
}) })
guard let instance = await instance else { return } 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 Task { @MainActor in

View File

@ -53,7 +53,8 @@ class MastodonController: ObservableObject {
let instanceFeatures = InstanceFeatures() let instanceFeatures = InstanceFeatures()
@Published private(set) var account: AccountMO? @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 instanceInfo: InstanceInfo!
@Published private(set) var nodeInfo: NodeInfo! @Published private(set) var nodeInfo: NodeInfo!
@Published private(set) var lists: [List] = [] @Published private(set) var lists: [List] = []
@ -63,7 +64,7 @@ class MastodonController: ObservableObject {
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var pendingOwnInstanceRequestCallbacks = [(Result<Instance, Client.Error>) -> Void]() private var pendingOwnInstanceRequestCallbacks = [(Result<InstanceV1, Client.Error>) -> Void]()
private var ownInstanceRequest: URLSessionTask? private var ownInstanceRequest: URLSessionTask?
var loggedIn: Bool { var loggedIn: Bool {
@ -107,9 +108,14 @@ class MastodonController: ObservableObject {
$instance $instance
.compactMap { $0 } .compactMap { $0 }
.sink { [unowned self] in .combineLatest($instanceV2)
self.updateActiveInstance(from: $0) .sink {[unowned self] (instance, v2) in
self.instanceInfo = InstanceInfo(instance: $0) var info = InstanceInfo(v1: instance)
if let v2 {
info.update(v2: v2)
}
self.instanceInfo = info
self.updateActiveInstance(from: info)
} }
.store(in: &cancellables) .store(in: &cancellables)
@ -217,6 +223,10 @@ class MastodonController: ObservableObject {
_ = try await (ownAccount, ownInstance) _ = try await (ownAccount, ownInstance)
if instanceFeatures.hasMastodonVersion(4, 0, 0) {
async let _ = try? getOwnInstanceV2()
}
loadLists() loadLists()
_ = await loadFilters() _ = await loadFilters()
await loadServerPreferences() await loadServerPreferences()
@ -277,7 +287,7 @@ class MastodonController: ObservableObject {
} }
} }
func getOwnInstance(completion: ((Instance) -> Void)? = nil) { func getOwnInstance(completion: ((InstanceV1) -> Void)? = nil) {
getOwnInstanceInternal(retryAttempt: 0) { getOwnInstanceInternal(retryAttempt: 0) {
if case let .success(instance) = $0 { if case let .success(instance) = $0 {
completion?(instance) completion?(instance)
@ -286,7 +296,7 @@ class MastodonController: ObservableObject {
} }
@MainActor @MainActor
func getOwnInstance() async throws -> Instance { func getOwnInstance() async throws -> InstanceV1 {
return try await withCheckedThrowingContinuation({ continuation in return try await withCheckedThrowingContinuation({ continuation in
getOwnInstanceInternal(retryAttempt: 0) { result in getOwnInstanceInternal(retryAttempt: 0) { result in
continuation.resume(with: result) continuation.resume(with: result)
@ -294,7 +304,7 @@ class MastodonController: ObservableObject {
}) })
} }
private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result<Instance, Client.Error>) -> Void)?) { private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result<InstanceV1, Client.Error>) -> Void)?) {
// this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks // this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks
assert(Thread.isMainThread) assert(Thread.isMainThread)
@ -306,7 +316,7 @@ class MastodonController: ObservableObject {
} }
if ownInstanceRequest == nil { if ownInstanceRequest == nil {
let request = Client.getInstance() let request = Client.getInstanceV1()
ownInstanceRequest = run(request) { (response) in ownInstanceRequest = run(request) { (response) in
switch response { switch response {
case .failure(let error): 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 because the accountPreferences instance is bound to the view context
@MainActor @MainActor
private func loadServerPreferences() async { 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 persistentContainer.performBackgroundTask { context in
if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first { if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first {
existing.update(from: instance) existing.update(from: info)
} else { } else {
let new = ActiveInstance(context: context) let new = ActiveInstance(context: context)
new.update(from: instance) new.update(from: info)
} }
if context.hasChanges { if context.hasChanges {
try? context.save() try? context.save()

View File

@ -22,18 +22,20 @@ public final class ActiveInstance: NSManagedObject {
@NSManaged public var maxStatusCharacters: Int @NSManaged public var maxStatusCharacters: Int
@NSManaged private var configurationData: Data? @NSManaged private var configurationData: Data?
@NSManaged private var pollsConfigurationData: Data? @NSManaged private var pollsConfigurationData: Data?
@NSManaged public var translation: Bool
@LazilyDecoding(from: \ActiveInstance.configurationData, fallback: nil) @LazilyDecoding(from: \ActiveInstance.configurationData, fallback: nil)
public var configuration: Instance.Configuration? public var configuration: InstanceV1.Configuration?
@LazilyDecoding(from: \ActiveInstance.pollsConfigurationData, fallback: nil) @LazilyDecoding(from: \ActiveInstance.pollsConfigurationData, fallback: nil)
public var pollsConfiguration: Instance.PollsConfiguration? public var pollsConfiguration: InstanceV1.PollsConfiguration?
func update(from instance: Instance) { func update(from info: InstanceInfo) {
self.version = instance.version self.version = info.version
self.maxStatusCharacters = instance.maxStatusCharacters ?? 500 self.maxStatusCharacters = info.maxStatusCharacters ?? 500
self.configuration = instance.configuration self.configuration = info.configuration
self.pollsConfiguration = instance.pollsConfiguration self.pollsConfiguration = info.pollsConfiguration
self.translation = info.translation
} }
} }
@ -43,7 +45,8 @@ extension InstanceInfo {
version: activeInstance.version, version: activeInstance.version,
maxStatusCharacters: activeInstance.maxStatusCharacters, maxStatusCharacters: activeInstance.maxStatusCharacters,
configuration: activeInstance.configuration, configuration: activeInstance.configuration,
pollsConfiguration: activeInstance.pollsConfiguration pollsConfiguration: activeInstance.pollsConfiguration,
translation: activeInstance.translation
) )
} }
} }

View File

@ -41,6 +41,7 @@
<attribute name="configurationData" optional="YES" attributeType="Binary"/> <attribute name="configurationData" optional="YES" attributeType="Binary"/>
<attribute name="maxStatusCharacters" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="maxStatusCharacters" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="pollsConfigurationData" optional="YES" attributeType="Binary"/> <attribute name="pollsConfigurationData" optional="YES" attributeType="Binary"/>
<attribute name="translation" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="version" optional="YES" attributeType="String"/> <attribute name="version" optional="YES" attributeType="String"/>
</entity> </entity>
<entity name="Filter" representedClassName="FilterMO" syncable="YES"> <entity name="Filter" representedClassName="FilterMO" syncable="YES">

View File

@ -162,7 +162,7 @@ class InstanceSelectorTableViewController: UITableViewController {
} }
let client = Client(baseURL: url, session: .appDefault) let client = Client(baseURL: url, session: .appDefault)
let request = Client.getInstance() let request = Client.getInstanceV1()
client.run(request) { (response) in client.run(request) { (response) in
var snapshot = self.dataSource.snapshot() var snapshot = self.dataSource.snapshot()
if snapshot.indexOfSection(.selected) != nil { if snapshot.indexOfSection(.selected) != nil {
@ -309,7 +309,7 @@ extension InstanceSelectorTableViewController {
case recommendedInstances case recommendedInstances
} }
enum Item: Equatable, Hashable { enum Item: Equatable, Hashable {
case selected(URL, Instance) case selected(URL, InstanceV1)
case recommended(InstanceSelector.Instance) case recommended(InstanceSelector.Instance)
static func ==(lhs: Item, rhs: Item) -> Bool { static func ==(lhs: Item, rhs: Item) -> Bool {

View File

@ -16,7 +16,7 @@ class InstanceTableViewCell: UITableViewCell {
@IBOutlet weak var adultLabel: UILabel! @IBOutlet weak var adultLabel: UILabel!
@IBOutlet weak var descriptionTextView: ContentTextView! @IBOutlet weak var descriptionTextView: ContentTextView!
var instance: Instance? var instance: InstanceV1?
var selectorInstance: InstanceSelector.Instance? var selectorInstance: InstanceSelector.Instance?
var thumbnailURL: URL? var thumbnailURL: URL?
@ -53,7 +53,7 @@ class InstanceTableViewCell: UITableViewCell {
updateThumbnail(url: instance.proxiedThumbnailURL) updateThumbnail(url: instance.proxiedThumbnailURL)
} }
func updateUI(instance: Instance) { func updateUI(instance: InstanceV1) {
self.instance = instance self.instance = instance
self.selectorInstance = nil self.selectorInstance = nil