Compare commits

...

6 Commits

15 changed files with 95 additions and 41 deletions

View File

@ -17,7 +17,7 @@ public class InstanceFeatures: ObservableObject {
private let _featuresUpdated = PassthroughSubject<Void, Never>() private let _featuresUpdated = PassthroughSubject<Void, Never>()
public var featuresUpdated: some Publisher<Void, Never> { _featuresUpdated } public var featuresUpdated: some Publisher<Void, Never> { _featuresUpdated }
@Published private var instanceType: InstanceType = .mastodon(.vanilla, nil) @Published @_spi(InstanceType) public private(set) var instanceType: InstanceType = .mastodon(.vanilla, nil)
@Published public private(set) var maxStatusChars = 500 @Published public private(set) var maxStatusChars = 500
@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?
@ -120,8 +120,11 @@ public class InstanceFeatures: ObservableObject {
public func update(instance: Instance, nodeInfo: NodeInfo?) { public func update(instance: Instance, nodeInfo: NodeInfo?) {
let ver = instance.version.lowercased() let ver = instance.version.lowercased()
// check glitch first b/c it still reports "mastodon" as the software in nodeinfo
if ver.contains("glitch") { if ver.contains("glitch") {
instanceType = .mastodon(.glitch, Version(string: ver)) instanceType = .mastodon(.glitch, Version(string: ver))
} else if nodeInfo?.software.name == "mastodon" {
instanceType = .mastodon(.vanilla, Version(string: ver))
} else if nodeInfo?.software.name == "hometown" { } else if nodeInfo?.software.name == "hometown" {
var mastoVersion: Version? var mastoVersion: Version?
var hometownVersion: Version? var hometownVersion: Version?
@ -157,6 +160,10 @@ public class InstanceFeatures: ObservableObject {
instanceType = .pleroma(.akkoma(akkomaVersion)) instanceType = .pleroma(.akkoma(akkomaVersion))
} else if ver.contains("pixelfed") { } else if ver.contains("pixelfed") {
instanceType = .pixelfed instanceType = .pixelfed
} else if nodeInfo?.software.name == "gotosocial" {
instanceType = .gotosocial
} else if ver.contains("calckey") {
instanceType = .calckey(nodeInfo?.software.version)
} else { } else {
instanceType = .mastodon(.vanilla, Version(string: ver)) instanceType = .mastodon(.vanilla, Version(string: ver))
} }
@ -190,10 +197,12 @@ public class InstanceFeatures: ObservableObject {
} }
extension InstanceFeatures { extension InstanceFeatures {
enum InstanceType { @_spi(InstanceType) public enum InstanceType {
case mastodon(MastodonType, Version?) case mastodon(MastodonType, Version?)
case pleroma(PleromaType) case pleroma(PleromaType)
case pixelfed case pixelfed
case gotosocial
case calckey(String?)
var isMastodon: Bool { var isMastodon: Bool {
if case .mastodon(_, _) = self { if case .mastodon(_, _) = self {
@ -230,7 +239,7 @@ extension InstanceFeatures {
} }
} }
enum MastodonType { @_spi(InstanceType) public enum MastodonType {
case vanilla case vanilla
case hometown(Version?) case hometown(Version?)
case glitch case glitch
@ -249,7 +258,7 @@ extension InstanceFeatures {
} }
} }
enum PleromaType { @_spi(InstanceType) public enum PleromaType {
case vanilla(Version?) case vanilla(Version?)
case akkoma(Version?) case akkoma(Version?)
@ -267,7 +276,7 @@ extension InstanceFeatures {
} }
extension InstanceFeatures { extension InstanceFeatures {
struct Version: Equatable, Comparable { @_spi(InstanceType) public struct Version: Equatable, Comparable, CustomStringConvertible {
private static let regex = try! NSRegularExpression(pattern: "^(\\d+)\\.(\\d+)\\.(\\d+).*$") private static let regex = try! NSRegularExpression(pattern: "^(\\d+)\\.(\\d+)\\.(\\d+).*$")
let major: Int let major: Int
@ -298,11 +307,15 @@ extension InstanceFeatures {
self.patch = patch self.patch = patch
} }
static func ==(lhs: Version, rhs: Version) -> Bool { 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 return lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch
} }
static func < (lhs: InstanceFeatures.Version, rhs: InstanceFeatures.Version) -> Bool { public static func < (lhs: InstanceFeatures.Version, rhs: InstanceFeatures.Version) -> Bool {
if lhs.major < rhs.major { if lhs.major < rhs.major {
return true return true
} else if lhs.major > rhs.major { } else if lhs.major > rhs.major {

View File

@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import WebURL
/** /**
The base Mastodon API client. The base Mastodon API client.
@ -186,9 +187,9 @@ public class Client {
case let .success(wellKnown, _): case let .success(wellKnown, _):
if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }), if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
let components = URLComponents(string: url.href), let href = WebURL(url.href),
components.host == self.baseURL.host { href.host == WebURL(self.baseURL)?.host {
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: components.path)) let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
self.run(nodeInfo, completion: completion) self.run(nodeInfo, completion: completion)
} }
} }
@ -507,6 +508,8 @@ extension Client {
// todo: support more status codes // todo: support more status codes
case .unexpectedStatus(413): case .unexpectedStatus(413):
return "HTTP 413: Payload Too Large" return "HTTP 413: Payload Too Large"
case .unexpectedStatus(429):
return "HTTP 429: Rate Limit Exceeded"
case .unexpectedStatus(let code): case .unexpectedStatus(let code):
return "HTTP Code \(code)" return "HTTP Code \(code)"
case .invalidRequest: case .invalidRequest:

View File

@ -64,7 +64,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog) self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog)
self.content = try container.decode(String.self, forKey: .content) self.content = try container.decode(String.self, forKey: .content)
self.createdAt = try container.decode(Date.self, forKey: .createdAt) self.createdAt = try container.decode(Date.self, forKey: .createdAt)
self.emojis = try container.decode([Emoji].self, forKey: .emojis) self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount) self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount)
self.favouritesCount = try container.decode(Int.self, forKey: .favouritesCount) self.favouritesCount = try container.decode(Int.self, forKey: .favouritesCount)
self.reblogged = try container.decodeIfPresent(Bool.self, forKey: .reblogged) self.reblogged = try container.decodeIfPresent(Bool.self, forKey: .reblogged)

View File

@ -114,10 +114,8 @@ private func createBookmarkAction(status: StatusMO, container: StatusSwipeAction
let (status, _) = try await container.mastodonController.run(request) let (status, _) = try await container.mastodonController.run(request)
container.mastodonController.persistentContainer.addOrUpdate(status: status) container.mastodonController.persistentContainer.addOrUpdate(status: status)
} catch { } catch {
if let toastable = container.toastableViewController { let config = ToastConfiguration(from: error, with: "Error \(bookmarked ? "Unb" : "B")ookmarking", in: container.navigationDelegate, retryAction: nil)
let config = ToastConfiguration(from: error, with: "Error \(bookmarked ? "Unb" : "B")ookmarking", in: toastable, retryAction: nil) container.navigationDelegate.showToast(configuration: config, animated: true)
toastable.showToast(configuration: config, animated: true)
}
} }
} }
} }

View File

@ -241,12 +241,12 @@ class FollowRequestNotificationCollectionViewCell: UICollectionViewListCell {
} catch let error as Client.Error { } catch let error as Client.Error {
acceptButton.isEnabled = true acceptButton.isEnabled = true
rejectButton.isEnabled = true rejectButton.isEnabled = true
if let toastable = delegate?.toastableViewController { if let delegate = delegate {
let config = ToastConfiguration(from: error, with: "Rejecting Follow", in: toastable) { [weak self] toast in let config = ToastConfiguration(from: error, with: "Rejecting Follow", in: delegate) { [weak self] toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
self?.rejectButtonPressed() self?.rejectButtonPressed()
} }
toastable.showToast(configuration: config, animated: true) delegate.showToast(configuration: config, animated: true)
} }
} }
} }
@ -268,12 +268,12 @@ class FollowRequestNotificationCollectionViewCell: UICollectionViewListCell {
acceptButton.isEnabled = true acceptButton.isEnabled = true
rejectButton.isEnabled = true rejectButton.isEnabled = true
if let toastable = delegate?.toastableViewController { if let delegate = delegate {
let config = ToastConfiguration(from: error, with: "Accepting Follow", in: toastable) { [weak self] toast in let config = ToastConfiguration(from: error, with: "Accepting Follow", in: delegate) { [weak self] toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
self?.acceptButtonPressed() self?.acceptButtonPressed()
} }
toastable.showToast(configuration: config, animated: true) delegate.showToast(configuration: config, animated: true)
} }
} }
} }

View File

@ -132,7 +132,7 @@ class PollFinishedNotificationCollectionViewCell: UICollectionViewListCell {
contentLabel.text = try! doc.text() contentLabel.text = try! doc.text()
pollView.mastodonController = mastodonController pollView.mastodonController = mastodonController
pollView.toastableViewController = delegate pollView.delegate = delegate
pollView.updateUI(status: status, poll: poll) pollView.updateUI(status: status, poll: poll)
} }

View File

@ -193,3 +193,7 @@ extension StatusActionAccountListViewController: ToastableViewController {
} }
} }
} }
extension StatusActionAccountListViewController: TuskerNavigationDelegate {
nonisolated var apiController: MastodonController! { mastodonController }
}

View File

@ -141,11 +141,13 @@ class EnhancedNavigationViewController: UINavigationController {
if self.interactivePushTransition.interactive { if self.interactivePushTransition.interactive {
// when an interactive push gesture is cancelled, make sure to adding the VC that was being pushed back onto the popped stack so it doesn't disappear // when an interactive push gesture is cancelled, make sure to adding the VC that was being pushed back onto the popped stack so it doesn't disappear
self.poppedViewControllers.insert(self.interactivePushTransition.pushingViewController!, at: 0) self.poppedViewControllers.insert(self.interactivePushTransition.pushingViewController!, at: 0)
} else { } else if self.interactivePopGestureRecognizer?.state == .ended {
// when an interactive pop gesture is cancelled (i.e. the user lifts their finger before it triggers), // when an interactive pop gesture is cancelled (i.e. the user lifts their finger before it triggers),
// the popViewController(animated:) method has already been called so the VC has already been added to the popped stack // the popViewController(animated:) method has already been called so the VC has already been added to the popped stack
// so we make sure to remove it, otherwise there could be duplicate VCs on the navigation stasck // so we make sure to remove it, otherwise there could be duplicate VCs on the navigation stasck
self.poppedViewControllers.remove(at: 0) if !self.poppedViewControllers.isEmpty {
self.poppedViewControllers.remove(at: 0)
}
} }
} }
}) })

View File

@ -409,10 +409,10 @@ extension MenuActionProvider {
} }
private func handleError(_ error: Client.Error, title: String) { private func handleError(_ error: Client.Error, title: String) {
if let toastable = self.toastableViewController { if let navigationDelegate {
let config = ToastConfiguration(from: error, with: title, in: toastable, retryAction: nil) let config = ToastConfiguration(from: error, with: title, in: navigationDelegate, retryAction: nil)
DispatchQueue.main.async { DispatchQueue.main.async {
toastable.showToast(configuration: config, animated: true) navigationDelegate.showToast(configuration: config, animated: true)
} }
} }
} }

View File

@ -10,7 +10,7 @@ import UIKit
import Combine import Combine
@MainActor @MainActor
protocol TimelineLikeCollectionViewController: UIViewController, TimelineLikeControllerDelegate, ToastableViewController { protocol TimelineLikeCollectionViewController: UIViewController, TimelineLikeControllerDelegate, TuskerNavigationDelegate {
associatedtype Section: TimelineLikeCollectionViewSection associatedtype Section: TimelineLikeCollectionViewSection
associatedtype Item: TimelineLikeCollectionViewItem where Item.TimelineItem == Self.TimelineItem associatedtype Item: TimelineLikeCollectionViewItem where Item.TimelineItem == Self.TimelineItem
associatedtype Error: TimelineLikeCollectionViewError associatedtype Error: TimelineLikeCollectionViewError

View File

@ -13,7 +13,7 @@ import ComposeUI
@MainActor @MainActor
protocol TuskerNavigationDelegate: UIViewController, ToastableViewController { protocol TuskerNavigationDelegate: UIViewController, ToastableViewController {
var apiController: MastodonController! { get } nonisolated var apiController: MastodonController! { get }
} }
extension TuskerNavigationDelegate { extension TuskerNavigationDelegate {

View File

@ -21,7 +21,7 @@ class StatusPollView: UIView {
}() }()
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
weak var toastableViewController: ToastableViewController? weak var delegate: TuskerNavigationDelegate?
private var statusID: String! private var statusID: String!
private(set) var poll: Poll? private(set) var poll: Poll?
@ -158,9 +158,9 @@ class StatusPollView: UIView {
DispatchQueue.main.async { DispatchQueue.main.async {
self.updateUI(status: self.mastodonController.persistentContainer.status(for: self.statusID)!, poll: self.poll) self.updateUI(status: self.mastodonController.persistentContainer.status(for: self.statusID)!, poll: self.poll)
if let toastable = self.toastableViewController { if let delegate = self.delegate {
let config = ToastConfiguration(from: error, with: "Error Voting", in: toastable, retryAction: nil) let config = ToastConfiguration(from: error, with: "Error Voting", in: delegate, retryAction: nil)
toastable.showToast(configuration: config, animated: true) delegate.showToast(configuration: config, animated: true)
} }
} }

View File

@ -392,12 +392,12 @@ class ProfileHeaderView: UIView {
} catch { } catch {
followButton.isEnabled = true followButton.isEnabled = true
followButton.configuration!.showsActivityIndicator = false followButton.configuration!.showsActivityIndicator = false
if let toastable = delegate?.toastableViewController { if let delegate = self.delegate {
let config = ToastConfiguration(from: error, with: "Error \(action)", in: toastable) { toast in let config = ToastConfiguration(from: error, with: "Error \(action)", in: delegate) { toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
self.followPressed() self.followPressed()
} }
toastable.showToast(configuration: config, animated: true) delegate.showToast(configuration: config, animated: true)
} }
} }
} }

View File

@ -213,7 +213,7 @@ extension StatusCollectionViewCell {
contentContainer.pollView.isHidden = status.poll == nil contentContainer.pollView.isHidden = status.poll == nil
contentContainer.pollView.mastodonController = mastodonController contentContainer.pollView.mastodonController = mastodonController
contentContainer.pollView.toastableViewController = delegate?.toastableViewController contentContainer.pollView.delegate = delegate
contentContainer.pollView.updateUI(status: status, poll: status.poll) contentContainer.pollView.updateUI(status: status, poll: status.poll)
} }

View File

@ -10,6 +10,7 @@ import UIKit
import Pachyderm import Pachyderm
import Sentry import Sentry
import OSLog import OSLog
@_spi(InstanceType) import InstanceFeatures
struct ToastConfiguration { struct ToastConfiguration {
var systemImageName: String? var systemImageName: String?
@ -39,7 +40,7 @@ struct ToastConfiguration {
} }
extension ToastConfiguration { extension ToastConfiguration {
init(from error: Error, with title: String, in viewController: UIViewController, retryAction: ((ToastView) -> Void)?) { init(from error: Error, with title: String, in viewController: TuskerNavigationDelegate, retryAction: ((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 {
@ -59,7 +60,7 @@ extension ToastConfiguration {
viewController.present(reporter, animated: true) viewController.present(reporter, animated: true)
} }
// TODO: this is a bizarre place to do this, but code path covers basically all errors // TODO: this is a bizarre place to do this, but code path covers basically all errors
captureError(error, title: title) captureError(error, in: viewController.apiController, title: title)
} else { } else {
self.subtitle = error.localizedDescription self.subtitle = error.localizedDescription
self.systemImageName = "exclamationmark.triangle" self.systemImageName = "exclamationmark.triangle"
@ -70,7 +71,7 @@ extension ToastConfiguration {
} }
} }
init(from error: Error, with title: String, in viewController: UIViewController, retryAction: @escaping @MainActor (ToastView) async -> Void) { init(from error: Error, with title: String, in viewController: TuskerNavigationDelegate, retryAction: @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)
@ -84,6 +85,8 @@ fileprivate extension Pachyderm.Client.Error {
switch type { switch type {
case .networkError(_): case .networkError(_):
return "wifi.exclamationmark" return "wifi.exclamationmark"
case .unexpectedStatus(429):
return "clock.badge.exclamationmark"
default: default:
return "exclamationmark.triangle" return "exclamationmark.triangle"
} }
@ -92,7 +95,7 @@ fileprivate extension Pachyderm.Client.Error {
private let toastErrorLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ToastError") private let toastErrorLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ToastError")
private func captureError(_ error: Client.Error, title: String) { private func captureError(_ error: Client.Error, in mastodonController: MastodonController, title: String) {
let event = Event(error: error) let event = Event(error: error)
event.message = SentryMessage(formatted: "\(title): \(error)") event.message = SentryMessage(formatted: "\(title): \(error)")
event.tags = [ event.tags = [
@ -125,6 +128,37 @@ private func captureError(_ error: Client.Error, title: String) {
code == "401" || code == "403" || code == "404" || code == "502" { code == "401" || code == "403" || code == "404" || code == "502" {
return return
} }
switch mastodonController.instanceFeatures.instanceType {
case .mastodon(let mastodonType, let mastodonVersion):
event.tags!["instance_type"] = "mastodon"
event.tags!["mastodon_version"] = mastodonVersion?.description ?? "unknown"
switch mastodonType {
case .vanilla:
break
case .hometown(_):
event.tags!["mastodon_type"] = "hometown"
case .glitch:
event.tags!["mastodon_type"] = "glitch"
}
case .pleroma(let pleromaType):
event.tags!["instance_type"] = "pleroma"
switch pleromaType {
case .vanilla(let version):
event.tags!["pleroma_version"] = version?.description ?? "unknown"
case .akkoma(let version):
event.tags!["pleroma_type"] = "akkoma"
event.tags!["pleroma_version"] = version?.description ?? "unknown"
}
case .pixelfed:
event.tags!["instance_type"] = "pixelfed"
case .gotosocial:
event.tags!["instance_type"] = "gotosocial"
case .calckey(let calckeyVersion):
event.tags!["instance_type"] = "calckey"
if let calckeyVersion {
event.tags!["calckey_version"] = calckeyVersion
}
}
SentrySDK.capture(event: event) SentrySDK.capture(event: event)
toastErrorLogger.error("\(title, privacy: .public): \(error), \(event.tags!.debugDescription, privacy: .public)") toastErrorLogger.error("\(title, privacy: .public): \(error), \(event.tags!.debugDescription, privacy: .public)")