// // InstanceFeatures.swift // Tusker // // Created by Shadowfacts on 1/23/22. // Copyright © 2022 Shadowfacts. All rights reserved. // import Foundation import Combine import Pachyderm public class InstanceFeatures: ObservableObject { private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; pleroma (.*)\\)", options: .caseInsensitive) private static let akkomaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; akkoma (.*)\\)", options: .caseInsensitive) private let _featuresUpdated = PassthroughSubject() public var featuresUpdated: some Publisher { _featuresUpdated } @Published @_spi(InstanceType) public private(set) var instanceType: InstanceType = .mastodon(.vanilla, nil) @Published public private(set) var maxStatusChars = 500 @Published public private(set) var charsReservedPerURL = 23 @Published public private(set) var maxPollOptionChars: Int? @Published public private(set) var maxPollOptionsCount: Int? public var localOnlyPosts: Bool { switch instanceType { case .mastodon(.hometown(_), _), .mastodon(.glitch, _): return true default: return false } } public var mastodonAttachmentRestrictions: Bool { instanceType.isMastodon } public var pollsAndAttachments: Bool { instanceType.isPleroma } public var boostToOriginalAudience: Bool { instanceType.isPleroma || instanceType.isMastodon } public var profilePinnedStatuses: Bool { switch instanceType { case .pixelfed: return false default: return true } } public var trends: Bool { instanceType.isMastodon } public var profileSuggestions: Bool { instanceType.isMastodon && hasMastodonVersion(3, 4, 0) } public var trendingStatusesAndLinks: Bool { instanceType.isMastodon && hasMastodonVersion(3, 5, 0) } public var reblogVisibility: Bool { (instanceType.isMastodon && hasMastodonVersion(2, 8, 0)) || (instanceType.isPleroma && hasPleromaVersion(2, 0, 0)) } public var probablySupportsMarkdown: Bool { switch instanceType { case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _): return true default: return false } } public var needsLocalOnlyEmojiHack: Bool { if case .mastodon(.glitch, _) = instanceType { return true } else { return false } } public var needsWideColorGamutHack: Bool { if case .mastodon(_, .some(let version)) = instanceType { return version < Version(4, 0, 0) } else { return true } } public var canFollowHashtags: Bool { hasMastodonVersion(4, 0, 0) } public var filtersV2: Bool { hasMastodonVersion(4, 0, 0) } public var notificationsAllowedTypes: Bool { hasMastodonVersion(3, 5, 0) } public var pollVotersCount: Bool { instanceType.isMastodon } public var createStatusWithLanguage: Bool { instanceType.isMastodon || instanceType.isPleroma(.akkoma(nil)) } public var editStatuses: Bool { // todo: does this require a particular akkoma version? hasMastodonVersion(3, 5, 0) || instanceType.isPleroma(.akkoma(nil)) } public var needsEditAttachmentsInSeparateRequest: Bool { instanceType.isPleroma(.akkoma(nil)) } public init() { } public func update(instance: Instance, nodeInfo: NodeInfo?) { let ver = instance.version.lowercased() // check glitch first b/c it still reports "mastodon" as the software in nodeinfo if ver.contains("glitch") { instanceType = .mastodon(.glitch, Version(string: ver)) } else if nodeInfo?.software.name == "mastodon" { instanceType = .mastodon(.vanilla, Version(string: ver)) } else if nodeInfo?.software.name == "hometown" { var mastoVersion: Version? var hometownVersion: Version? let parts = ver.split(separator: "+") if parts.count == 2, let first = Version(string: String(parts[0])) { if first > Version(1, 0, 8) { // like 3.5.5+hometown-1.0.9 mastoVersion = first if parts[1].starts(with: "hometown-") { hometownVersion = Version(string: String(parts[1][parts[1].index(parts[1].startIndex, offsetBy: "hometown-".count + 1)...])) } } else { // like "1.0.6+3.5.2" hometownVersion = first mastoVersion = Version(string: String(parts[1])) } } else { mastoVersion = Version(string: ver) } instanceType = .mastodon(.hometown(hometownVersion), mastoVersion) } else if ver.contains("pleroma") { var pleromaVersion: Version? if let match = InstanceFeatures.pleromaVersionRegex.firstMatch(in: ver, range: NSRange(location: 0, length: ver.utf16.count)) { pleromaVersion = Version(string: (ver as NSString).substring(with: match.range(at: 1))) } instanceType = .pleroma(.vanilla(pleromaVersion)) } else if ver.contains("akkoma") { var akkomaVersion: Version? if let match = InstanceFeatures.akkomaVersionRegex.firstMatch(in: ver, range: NSRange(location: 0, length: ver.utf16.count)) { akkomaVersion = Version(string: (ver as NSString).substring(with: match.range(at: 1))) } instanceType = .pleroma(.akkoma(akkomaVersion)) } else if ver.contains("pixelfed") { instanceType = .pixelfed } else if nodeInfo?.software.name == "gotosocial" { instanceType = .gotosocial } else if ver.contains("calckey") { instanceType = .calckey(nodeInfo?.software.version) } else { instanceType = .mastodon(.vanilla, Version(string: ver)) } maxStatusChars = instance.maxStatusCharacters ?? 500 charsReservedPerURL = instance.configuration?.statuses.charactersReservedPerURL ?? 23 // default Mastodon link length if let pollsConfig = instance.pollsConfiguration { maxPollOptionChars = pollsConfig.maxCharactersPerOption maxPollOptionsCount = pollsConfig.maxOptions } _featuresUpdated.send() } public func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool { if case .mastodon(_, .some(let version)) = instanceType { return version >= Version(major, minor, patch) } else { return false } } func hasPleromaVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool { switch instanceType { case .pleroma(.vanilla(.some(let version))), .pleroma(.akkoma(.some(let version))): return version >= Version(major, minor, patch) default: return false } } } extension InstanceFeatures { @_spi(InstanceType) public enum InstanceType { case mastodon(MastodonType, Version?) case pleroma(PleromaType) case pixelfed case gotosocial case calckey(String?) var isMastodon: Bool { if case .mastodon(_, _) = self { return true } else { return false } } func isMastodon(_ subtype: MastodonType) -> Bool { if case .mastodon(let t, _) = self, t.equalsIgnoreVersion(subtype) { return true } else { return false } } var isPleroma: Bool { if case .pleroma(_) = self { return true } else { return false } } func isPleroma(_ subtype: PleromaType) -> Bool { if case .pleroma(let t) = self, t.equalsIgnoreVersion(subtype) { return true } else { return false } } } @_spi(InstanceType) public enum MastodonType { case vanilla case hometown(Version?) case glitch func equalsIgnoreVersion(_ other: MastodonType) -> Bool { switch (self, other) { case (.vanilla, .vanilla): return true case (.hometown(_), .hometown(_)): return true case (.glitch, .glitch): return true default: return false } } } @_spi(InstanceType) public enum PleromaType { case vanilla(Version?) case akkoma(Version?) func equalsIgnoreVersion(_ other: PleromaType) -> Bool { switch (self, other) { case (.vanilla(_), .vanilla(_)): return true case (.akkoma(_), .akkoma(_)): return true default: return false } } } } extension InstanceFeatures { @_spi(InstanceType) public struct Version: Equatable, Comparable, CustomStringConvertible { private static let regex = try! NSRegularExpression(pattern: "^(\\d+)\\.(\\d+)\\.(\\d+).*$") let major: Int let minor: Int let patch: Int init(_ major: Int, _ minor: Int, _ patch: Int) { self.major = major self.minor = minor self.patch = patch } init?(string: String) { guard let match = Version.regex.firstMatch(in: string, range: NSRange(location: 0, length: string.utf16.count)), match.numberOfRanges == 4 else { return nil } let majorStr = (string as NSString).substring(with: match.range(at: 1)) let minorStr = (string as NSString).substring(with: match.range(at: 2)) let patchStr = (string as NSString).substring(with: match.range(at: 3)) guard let major = Int(majorStr), let minor = Int(minorStr), let patch = Int(patchStr) else { return nil } self.major = major self.minor = minor self.patch = patch } public var description: String { "\(major).\(minor).\(patch)" } public static func ==(lhs: Version, rhs: Version) -> Bool { return lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch } public static func < (lhs: InstanceFeatures.Version, rhs: InstanceFeatures.Version) -> Bool { if lhs.major < rhs.major { return true } else if lhs.major > rhs.major { return false } else if lhs.minor < rhs.minor { return true } else if lhs.minor > rhs.minor { return false } else if lhs.patch < rhs.patch { return true } else { return false } } } }