// // InstanceFeatures.swift // Tusker // // Created by Shadowfacts on 1/23/22. // Copyright © 2022 Shadowfacts. All rights reserved. // import Foundation import Combine import Pachyderm public final class InstanceFeatures: ObservableObject { private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; (pleroma|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? @Published public private(set) var mediaAttachmentsConfiguration: InstanceV1.MediaAttachmentsConfiguration? @Published public private(set) var translation: Bool = false public var localOnlyPosts: Bool { switch instanceType { case .mastodon(.hometown(_), _), .mastodon(.glitch, _): return true case .pleroma(.akkoma(_)): return true default: return false } } /// Instance types that use a separate visibility to indicate local-only posts. public var localOnlyPostsVisibility: Bool { if case .pleroma(.akkoma(_)) = instanceType { return true } else { 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(_), _), .firefish(_): 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(_, let version) = instanceType { return version < Version(4, 0, 0) } else { return true } } public var canFollowHashtags: Bool { if case .mastodon(_, let version) = instanceType { return version >= Version(4, 0, 0) } else if case .pleroma(.akkoma(let version)) = instanceType { return version >= Version(3, 4, 0) } else { return false } } 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 { switch instanceType { case .mastodon(_, let v) where v >= Version(3, 5, 0): return true case .pleroma(.vanilla(let v)) where v >= Version(2, 5, 0): return true case .pleroma(.akkoma(_)): return true default: return false } } public var statusEditNotifications: Bool { // pleroma doesn't seem to support 'update' type notifications, even though it supports edits hasMastodonVersion(3, 5, 0) } public var statusNotifications: Bool { // pleroma doesn't support notifications for new posts from an account hasMastodonVersion(3, 3, 0) } public var needsEditAttachmentsInSeparateRequest: Bool { instanceType.isPleroma(.akkoma(nil)) } public var composeDirectStatuses: Bool { if case .pixelfed = instanceType { return false } else { return true } } public var searchOperators: Bool { hasMastodonVersion(4, 2, 0) } public var hasServerPreferences: Bool { hasMastodonVersion(2, 8, 0) } public var listRepliesPolicy: Bool { hasMastodonVersion(3, 3, 0) } public var exclusiveLists: Bool { hasMastodonVersion(4, 2, 0) || instanceType.isMastodon(.hometown(nil)) } public init() { } public func update(instance: InstanceInfo, 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 let match = InstanceFeatures.pleromaVersionRegex.firstMatch(in: ver, range: NSRange(location: 0, length: ver.utf16.count)) { var pleromaVersion: Version? let type = (ver as NSString).substring(with: match.range(at: 1)) pleromaVersion = Version(string: (ver as NSString).substring(with: match.range(at: 2))) if type == "akkoma" { instanceType = .pleroma(.akkoma(pleromaVersion)) } else { instanceType = .pleroma(.vanilla(pleromaVersion)) } } else if ver.contains("pixelfed") { instanceType = .pixelfed } else if nodeInfo?.software.name == "gotosocial" { instanceType = .gotosocial } else if ver.contains("firefish") || ver.contains("iceshrimp") || ver.contains("calckey") { instanceType = .firefish(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 } mediaAttachmentsConfiguration = instance.configuration?.mediaAttachments translation = instance.translation _featuresUpdated.send() } public func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool { if case .mastodon(_, 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(let version)), .pleroma(.akkoma(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 firefish(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 } } } }