Compare commits

..

10 Commits

23 changed files with 586 additions and 284 deletions

View File

@ -68,29 +68,32 @@ public class Client {
@discardableResult
public func run<Result>(_ request: Request<Result>, completion: @escaping Callback<Result>) -> URLSessionTask? {
guard let request = createURLRequest(request: request) else {
completion(.failure(Error.invalidRequest))
guard let urlRequest = createURLRequest(request: request) else {
completion(.failure(Error(request: request, type: .invalidRequest)))
return nil
}
let task = session.dataTask(with: request) { data, response, error in
let task = session.dataTask(with: urlRequest) { data, response, error in
if let error = error {
completion(.failure(.networkError(error)))
completion(.failure(Error(request: request, type: .networkError(error))))
return
}
guard let data = data,
let response = response as? HTTPURLResponse else {
completion(.failure(.invalidResponse))
completion(.failure(Error(request: request, type: .invalidResponse)))
return
}
guard response.statusCode == 200 else {
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
let error: Error = mastodonError.flatMap { .mastodonError($0.description) } ?? .unexpectedStatus(response.statusCode)
completion(.failure(error))
let type: ErrorType = mastodonError.flatMap { .mastodonError($0.description) } ?? .unexpectedStatus(response.statusCode)
completion(.failure(Error(request: request, type: type)))
return
}
guard let result = try? Client.decoder.decode(Result.self, from: data) else {
completion(.failure(.invalidModel))
let result: Result
do {
result = try Client.decoder.decode(Result.self, from: data)
} catch {
completion(.failure(Error(request: request, type: .invalidModel(error))))
return
}
let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
@ -103,7 +106,7 @@ public class Client {
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
components.path = request.path
components.path = request.endpoint.path
components.queryItems = request.queryParameters.isEmpty ? nil : request.queryParameters.queryItems
guard let url = components.url else { return nil }
var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval)
@ -163,7 +166,7 @@ public class Client {
if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
let components = URLComponents(string: url.href),
components.host == self.baseURL.host {
let nodeInfo = Request<NodeInfo>(method: .get, path: components.path)
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: components.path))
self.run(nodeInfo, completion: completion)
}
}
@ -392,16 +395,19 @@ public class Client {
}
extension Client {
public enum Error: LocalizedError {
case networkError(Swift.Error)
case unexpectedStatus(Int)
case invalidRequest
case invalidResponse
case invalidModel
case mastodonError(String)
public struct Error: LocalizedError {
public let requestMethod: Method
public let requestEndpoint: Endpoint
public let type: ErrorType
init<ResultType: Decodable>(request: Request<ResultType>, type: ErrorType) {
self.requestMethod = request.method
self.requestEndpoint = request.endpoint
self.type = type
}
public var localizedDescription: String {
switch self {
switch type {
case .networkError(let error):
return "Network Error: \(error.localizedDescription)"
// todo: support more status codes
@ -413,11 +419,19 @@ extension Client {
return "Invalid Request"
case .invalidResponse:
return "Invalid Response"
case .invalidModel:
case .invalidModel(_):
return "Invalid Model"
case .mastodonError(let error):
return "Server Error: \(error)"
}
}
}
public enum ErrorType: LocalizedError {
case networkError(Swift.Error)
case unexpectedStatus(Int)
case invalidRequest
case invalidResponse
case invalidModel(Swift.Error)
case mastodonError(String)
}
}

View File

@ -17,7 +17,7 @@ public enum Timeline {
}
extension Timeline {
var endpoint: String {
var endpoint: Endpoint {
switch self {
case .home:
return "/api/v1/timelines/home"

View File

@ -0,0 +1,62 @@
//
// Endpoint.swift
// Pachyderm
//
// Created by Shadowfacts on 3/29/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
public struct Endpoint: ExpressibleByStringInterpolation, CustomStringConvertible {
let components: [Component]
public init(stringLiteral value: StringLiteralType) {
self.components = [.literal(value)]
}
public init(stringInterpolation: StringInterpolation) {
self.components = stringInterpolation.components
}
var path: String {
components.map {
switch $0 {
case .literal(let s), .interpolated(let s):
return s
}
}.joined(separator: "")
}
public var description: String {
components.map {
switch $0 {
case .literal(let s):
return s
case .interpolated(_):
return "<redacted>"
}
}.joined(separator: "")
}
public struct StringInterpolation: StringInterpolationProtocol {
var components = [Component]()
public init(literalCapacity: Int, interpolationCount: Int) {
}
public mutating func appendLiteral(_ literal: StringLiteralType) {
components.append(.literal(literal))
}
public mutating func appendInterpolation(_ value: String) {
components.append(.interpolated(value))
}
}
enum Component {
case literal(String)
case interpolated(String)
}
}

View File

@ -8,12 +8,12 @@
import Foundation
enum Method {
public enum Method {
case get, post, put, patch, delete
}
extension Method {
var name: String {
public var name: String {
switch self {
case .get:
return "GET"

View File

@ -10,13 +10,13 @@ import Foundation
public struct Request<ResultType: Decodable> {
let method: Method
let path: String
let endpoint: Endpoint
let body: Body
var queryParameters: [Parameter]
init(method: Method, path: String, body: Body = EmptyBody(), queryParameters: [Parameter] = []) {
init(method: Method, path: Endpoint, body: Body = EmptyBody(), queryParameters: [Parameter] = []) {
self.method = method
self.path = path
self.endpoint = path
self.body = body
self.queryParameters = queryParameters
}

View File

@ -12,7 +12,7 @@ public class InstanceSelector {
private static let decoder = JSONDecoder()
public static func getInstances(category: String?, completion: @escaping Client.Callback<[Instance]>) {
public static func getInstances(category: String?, completion: @escaping (Result<[Instance], Client.ErrorType>) -> Void) {
let url: URL
if let category = category {
url = URL(string: "https://api.joinmastodon.org/servers?category=\(category)")!
@ -34,11 +34,14 @@ public class InstanceSelector {
completion(.failure(.unexpectedStatus(response.statusCode)))
return
}
guard let result = try? decoder.decode([Instance].self, from: data) else {
completion(.failure(Client.Error.invalidModel))
let result: [Instance]
do {
result = try decoder.decode([Instance].self, from: data)
} catch {
completion(.failure(.invalidModel(error)))
return
}
completion(.success(result, nil))
completion(.success(result))
}
task.resume()
}

View File

@ -69,6 +69,8 @@
D6109A0D214599E100432DC2 /* RequestRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6109A0C214599E100432DC2 /* RequestRange.swift */; };
D6109A0F21459B6900432DC2 /* Pagination.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6109A0E21459B6900432DC2 /* Pagination.swift */; };
D6109A11214607D500432DC2 /* Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6109A10214607D500432DC2 /* Timeline.swift */; };
D6114E0927F3EA3D0080E273 /* CrashReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E0827F3EA3D0080E273 /* CrashReporterViewController.swift */; };
D6114E0B27F3F6EA0080E273 /* Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E0A27F3F6EA0080E273 /* Endpoint.swift */; };
D611C2CF232DC61100C86A49 /* HashtagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */; };
D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */; };
D61AC1D3232E928600C54D2D /* InstanceSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D2232E928600C54D2D /* InstanceSelector.swift */; };
@ -340,8 +342,8 @@
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; };
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; };
D6F1F9DF27B0613300CB7D88 /* WebURL in Frameworks */ = {isa = PBXBuildFile; productRef = D6F1F9DE27B0613300CB7D88 /* WebURL */; settings = {ATTRIBUTES = (Required, ); }; };
D6F2E965249E8BFD005846BB /* CrashReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* CrashReporterViewController.swift */; };
D6F2E966249E8BFD005846BB /* CrashReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* CrashReporterViewController.xib */; };
D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; };
D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; };
D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F953EF21251A2900CF0F2B /* MastodonController.swift */; };
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */; };
/* End PBXBuildFile section */
@ -480,6 +482,8 @@
D6109A0C214599E100432DC2 /* RequestRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestRange.swift; sourceTree = "<group>"; };
D6109A0E21459B6900432DC2 /* Pagination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pagination.swift; sourceTree = "<group>"; };
D6109A10214607D500432DC2 /* Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timeline.swift; sourceTree = "<group>"; };
D6114E0827F3EA3D0080E273 /* CrashReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashReporterViewController.swift; sourceTree = "<group>"; };
D6114E0A27F3F6EA0080E273 /* Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpoint.swift; sourceTree = "<group>"; };
D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTableViewCell.swift; sourceTree = "<group>"; };
D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = HashtagTableViewCell.xib; sourceTree = "<group>"; };
D61AC1D2232E928600C54D2D /* InstanceSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelector.swift; sourceTree = "<group>"; };
@ -757,8 +761,8 @@
D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollapseButton.swift; sourceTree = "<group>"; };
D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSplitViewController.swift; sourceTree = "<group>"; };
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = "<group>"; };
D6F2E963249E8BFD005846BB /* CrashReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashReporterViewController.swift; sourceTree = "<group>"; };
D6F2E964249E8BFD005846BB /* CrashReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CrashReporterViewController.xib; sourceTree = "<group>"; };
D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReporterViewController.swift; sourceTree = "<group>"; };
D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IssueReporterViewController.xib; sourceTree = "<group>"; };
D6F953EF21251A2900CF0F2B /* MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonController.swift; sourceTree = "<group>"; };
D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSwitchingContainerViewController.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@ -872,6 +876,7 @@
D61099D12144B2E600432DC2 /* Body.swift */,
D61099D32144B32E00432DC2 /* Parameter.swift */,
D61099D52144B4B200432DC2 /* FormAttachment.swift */,
D6114E0A27F3F6EA0080E273 /* Endpoint.swift */,
);
path = Request;
sourceTree = "<group>";
@ -1681,8 +1686,9 @@
D6F2E960249E772F005846BB /* Crash Reporter */ = {
isa = PBXGroup;
children = (
D6F2E963249E8BFD005846BB /* CrashReporterViewController.swift */,
D6F2E964249E8BFD005846BB /* CrashReporterViewController.xib */,
D6114E0827F3EA3D0080E273 /* CrashReporterViewController.swift */,
D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */,
D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */,
);
path = "Crash Reporter";
sourceTree = "<group>";
@ -1950,7 +1956,7 @@
D64BC19023C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib in Resources */,
D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */,
D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */,
D6F2E966249E8BFD005846BB /* CrashReporterViewController.xib in Resources */,
D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */,
D662AEF0263A3B880082A153 /* PollFinishedTableViewCell.xib in Resources */,
D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */,
D6DEA0DF268400C300FE896A /* ConfirmLoadMoreTableViewCell.xib in Resources */,
@ -2070,6 +2076,7 @@
D61099DC2144BDBF00432DC2 /* Response.swift in Sources */,
D61099F72145693500432DC2 /* PushSubscription.swift in Sources */,
D61099F5214568C300432DC2 /* Notification.swift in Sources */,
D6114E0B27F3F6EA0080E273 /* Endpoint.swift in Sources */,
D61099EF214566C000432DC2 /* Instance.swift in Sources */,
D61099D22144B2E600432DC2 /* Body.swift in Sources */,
D623A53F2635F6910095BD04 /* Poll.swift in Sources */,
@ -2144,7 +2151,7 @@
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */,
D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */,
D620483623D38075008A63EF /* ContentTextView.swift in Sources */,
D6F2E965249E8BFD005846BB /* CrashReporterViewController.swift in Sources */,
D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */,
D6A57408255C53EC00674551 /* ComposeTextViewCaretScrolling.swift in Sources */,
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */,
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */,
@ -2318,6 +2325,7 @@
D677284E24ECC01D00C732D3 /* Draft.swift in Sources */,
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,
D6114E0927F3EA3D0080E273 /* CrashReporterViewController.swift in Sources */,
D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */,
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,

View File

@ -67,28 +67,54 @@ class MastodonController: ObservableObject {
return client.run(request, completion: completion)
}
func registerApp(completion: @escaping (_ clientID: String, _ clientSecret: String) -> Void) {
guard client.clientID == nil,
client.clientSecret == nil else {
func run<Result>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
return try await withCheckedThrowingContinuation({ continuation in
client.run(request) { response in
switch response {
case .failure(let error):
continuation.resume(throwing: error)
case .success(let result, let pagination):
continuation.resume(returning: (result, pagination))
}
}
})
}
completion(client.clientID!, client.clientSecret!)
return
}
client.registerApp(name: "Tusker", redirectURI: "tusker://oauth", scopes: [.read, .write, .follow]) { response in
guard case let .success(app, _) = response else { fatalError() }
/// - Returns: A tuple of client ID and client secret.
func registerApp() async throws -> (String, String) {
if let clientID = client.clientID,
let clientSecret = client.clientSecret {
return (clientID, clientSecret)
} else {
let app: RegisteredApplication = try await withCheckedThrowingContinuation({ continuation in
client.registerApp(name: "Tusker", redirectURI: "tusker://oauth", scopes: [.read, .write, .follow]) { response in
switch response {
case .failure(let error):
continuation.resume(throwing: error)
case .success(let app, _):
continuation.resume(returning: app)
}
}
})
self.client.clientID = app.clientID
self.client.clientSecret = app.clientSecret
completion(app.clientID, app.clientSecret)
return (app.clientID, app.clientSecret)
}
}
func authorize(authorizationCode: String, completion: @escaping (_ accessToken: String) -> Void) {
client.getAccessToken(authorizationCode: authorizationCode, redirectURI: "tusker://oauth") { response in
guard case let .success(settings, _) = response else { fatalError() }
self.client.accessToken = settings.accessToken
completion(settings.accessToken)
}
/// - Returns: The access token
func authorize(authorizationCode: String) async throws -> String {
return try await withCheckedThrowingContinuation({ continuation in
client.getAccessToken(authorizationCode: authorizationCode, redirectURI: "tusker://oauth") { response in
switch response {
case .failure(let error):
continuation.resume(throwing: error)
case .success(let settings, _):
self.client.accessToken = settings.accessToken
continuation.resume(returning: settings.accessToken)
}
}
})
}
func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) {
@ -120,6 +146,18 @@ class MastodonController: ObservableObject {
}
}
func getOwnAccount() async throws -> Account {
if let account = account {
return account
} else {
return try await withCheckedThrowingContinuation({ continuation in
self.getOwnAccount { result in
continuation.resume(with: result)
}
})
}
}
func getOwnInstance(completion: ((Instance) -> Void)? = nil) {
getOwnInstanceInternal(retryAttempt: 0, completion: completion)
}

View File

@ -127,7 +127,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
return
}
window!.rootViewController = CrashReporterViewController.create(report: report)
window!.rootViewController = CrashReporterViewController.create(report: report, delegate: self)
#endif
}
@ -208,10 +208,8 @@ extension MainSceneDelegate: OnboardingViewControllerDelegate {
}
}
extension MainSceneDelegate: MFMailComposeViewControllerDelegate {
func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
controller.dismiss(animated: true) {
self.showAppOrOnboardingUI()
}
extension MainSceneDelegate: IssueReporterViewControllerDelegate {
func didDismissReporter() {
showAppOrOnboardingUI()
}
}

View File

@ -143,7 +143,9 @@ class AssetCollectionViewController: UIViewController, UICollectionViewDelegate
switch PHPhotoLibrary.authorizationStatus(for: .readWrite) {
case .notDetermined:
PHPhotoLibrary.requestAuthorization(for: .readWrite) { (_) in
self.loadAssets()
DispatchQueue.main.async {
self.loadAssets()
}
}
return

View File

@ -157,7 +157,7 @@ class ConversationTableViewController: EnhancedTableViewController {
DispatchQueue.main.async {
self.loadingState = .unloaded
let config = ToastConfiguration(from: error, with: "Error Loading Status") { [weak self] (toast) in
let config = ToastConfiguration(from: error, with: "Error Loading Status", in: self) { [weak self] (toast) in
toast.dismissToast(animated: true)
self?.loadMainStatus()
}
@ -210,7 +210,7 @@ class ConversationTableViewController: EnhancedTableViewController {
DispatchQueue.main.async {
self.loadingState = .loadedMain
let config = ToastConfiguration(from: error, with: "Error Loading Content") { [weak self] (toast) in
let config = ToastConfiguration(from: error, with: "Error Loading Content", in: self) { [weak self] (toast) in
toast.dismissToast(animated: true)
self?.loadContext(for: mainStatus)
}

View File

@ -2,37 +2,36 @@
// CrashReporterViewController.swift
// Tusker
//
// Created by Shadowfacts on 6/20/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
// Created by Shadowfacts on 3/29/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import CrashReporter
import MessageUI
class CrashReporterViewController: UIViewController {
class CrashReporterViewController: IssueReporterViewController {
private let report: PLCrashReport
private var reportText: String!
private var reportFilename: String {
let timestamp = ISO8601DateFormatter().string(from: report.systemInfo.timestamp)
return "Tusker-crash-\(timestamp).crash"
override var preamble: String {
"Tusker has detected that it crashed the last time it was running. You can email the report to the developer or skip sending and continue to the app. You may review the report below before sending.\n\nIf you choose to send the report, please include any additional details about what you were doing prior to the crash that may be pertinent."
}
@IBOutlet weak var crashReportTextView: UITextView!
@IBOutlet weak var sendReportButton: UIButton!
static func create(report: PLCrashReport) -> UINavigationController {
let nav = UINavigationController(rootViewController: CrashReporterViewController(report: report))
nav.navigationBar.prefersLargeTitles = true
return nav
override var subject: String {
"Tusker Crash Report"
}
private init(report: PLCrashReport){
static func create(report: PLCrashReport, delegate: IssueReporterViewControllerDelegate) -> UINavigationController {
return create(CrashReporterViewController(report: report, delegate: delegate))
}
private init(report: PLCrashReport, delegate: IssueReporterViewControllerDelegate) {
self.report = report
let reportText = PLCrashReportTextFormatter.stringValue(for: report, with: PLCrashReportTextFormatiOS)!
let timestamp = ISO8601DateFormatter().string(from: report.systemInfo.timestamp)
let reportFilename = "Tusker-crash-\(timestamp).crash"
super.init(nibName: "CrashReporterViewController", bundle: .main)
super.init(reportText: reportText, reportFilename: reportFilename, delegate: delegate)
}
required init?(coder: NSCoder) {
@ -43,91 +42,6 @@ class CrashReporterViewController: UIViewController {
super.viewDidLoad()
navigationItem.title = NSLocalizedString("Crash Detected", comment: "crash reporter title")
navigationItem.largeTitleDisplayMode = .always
crashReportTextView.font = .monospacedSystemFont(ofSize: 14, weight: .regular)
reportText = PLCrashReportTextFormatter.stringValue(for: report, with: PLCrashReportTextFormatiOS)!
let info = "Tusker has detected that it crashed the last time it was running. You can email the report to the developer or skip sending and continue to the app. You may review the report below before sending.\n\nIf you choose to send the report, please include any additional details about what you were doing prior to the crash that may be pertinent.\n\n"
let attributed = NSMutableAttributedString()
attributed.append(NSAttributedString(string: info, attributes: [
NSAttributedString.Key.font: UIFont.systemFont(ofSize: 17),
NSAttributedString.Key.foregroundColor: UIColor.label
]))
attributed.append(NSAttributedString(string: reportText, attributes: [
NSAttributedString.Key.font: UIFont.monospacedSystemFont(ofSize: 14, weight: .regular),
NSAttributedString.Key.foregroundColor: UIColor.label
]))
crashReportTextView.attributedText = attributed
sendReportButton.layer.cornerRadius = 12.5
sendReportButton.layer.masksToBounds = true
sendReportButton.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(sendReportButtonLongPressed)))
}
private func updateSendReportButtonColor(lightened: Bool, animate: Bool) {
let color: UIColor
if lightened {
var hue: CGFloat = 0, saturation: CGFloat = 0, brightness: CGFloat = 0, alpha: CGFloat = 0
UIColor.systemBlue.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
color = UIColor(hue: hue, saturation: 0.85 * saturation, brightness: brightness, alpha: alpha)
} else {
color = .systemBlue
}
if animate {
UIView.animate(withDuration: 0.25) {
self.sendReportButton.backgroundColor = color
}
} else {
sendReportButton.backgroundColor = color
}
}
@IBAction func sendReportTouchDown(_ sender: Any) {
updateSendReportButtonColor(lightened: true, animate: false)
}
@IBAction func sendReportButtonTouchDragExit(_ sender: Any) {
updateSendReportButtonColor(lightened: false, animate: true)
}
@IBAction func sendReportButtonTouchDragEnter(_ sender: Any) {
updateSendReportButtonColor(lightened: true, animate: true)
}
@IBAction func sendReportTouchUpInside(_ sender: Any) {
updateSendReportButtonColor(lightened: false, animate: true)
let composeVC = MFMailComposeViewController()
composeVC.mailComposeDelegate = self
composeVC.setToRecipients(["me@shadowfacts.net"])
composeVC.setSubject("Tusker Crash Report")
let data = reportText.data(using: .utf8)!
composeVC.addAttachmentData(data, mimeType: "text/plain", fileName: reportFilename)
self.present(composeVC, animated: true)
}
@objc func sendReportButtonLongPressed() {
let dir = FileManager.default.temporaryDirectory
let url = dir.appendingPathComponent(reportFilename)
try! reportText.data(using: .utf8)!.write(to: url)
let activityController = UIActivityViewController(activityItems: [url], applicationActivities: nil)
present(activityController, animated: true)
}
@IBAction func cancelPressed(_ sender: Any) {
(view.window!.windowScene!.delegate as! MainSceneDelegate).showAppOrOnboardingUI()
}
}
extension CrashReporterViewController: MFMailComposeViewControllerDelegate {
func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
controller.dismiss(animated: true) {
(self.view.window!.windowScene!.delegate as! MainSceneDelegate).showAppOrOnboardingUI()
}
}
}

View File

@ -0,0 +1,145 @@
//
// IssueReporterViewController.swift
// Tusker
//
// Created by Shadowfacts on 6/20/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import CrashReporter
import MessageUI
protocol IssueReporterViewControllerDelegate: AnyObject {
func didDismissReporter()
}
class IssueReporterViewController: UIViewController {
static func create(_ self: IssueReporterViewController) -> UINavigationController {
let nav = UINavigationController(rootViewController: self)
nav.navigationBar.prefersLargeTitles = true
return nav
}
static func create(reportText: String, reportFilename: String? = nil, delegate: IssueReporterViewControllerDelegate) -> UINavigationController {
let filename = reportFilename ?? "Tusker-error-\(ISO8601DateFormatter().string(from: Date())).txt"
return create(IssueReporterViewController(reportText: reportText, reportFilename: filename, delegate: delegate))
}
let reportText: String
let reportFilename: String
private weak var delegate: IssueReporterViewControllerDelegate?
var preamble: String {
"Tusker has encountered an error. You can email a report to the developer. You may review the report below before sending.\n\nIf you choose to send the report, please include any additional details about what you were doing prior that may be pertinent."
}
var subject: String {
"Tusker Error Report"
}
@IBOutlet weak var crashReportTextView: UITextView!
@IBOutlet weak var sendReportButton: UIButton!
init(reportText: String, reportFilename: String, delegate: IssueReporterViewControllerDelegate?) {
self.reportText = reportText
self.reportFilename = reportFilename
self.delegate = delegate
super.init(nibName: "IssueReporterViewController", bundle: .main)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.title = "Report an Error"
navigationItem.largeTitleDisplayMode = .always
crashReportTextView.font = .monospacedSystemFont(ofSize: 14, weight: .regular)
let attributed = NSMutableAttributedString()
attributed.append(NSAttributedString(string: preamble, attributes: [
NSAttributedString.Key.font: UIFont.systemFont(ofSize: 17),
NSAttributedString.Key.foregroundColor: UIColor.label
]))
attributed.append(NSAttributedString(string: "\n\n"))
attributed.append(NSAttributedString(string: reportText, attributes: [
NSAttributedString.Key.font: UIFont.monospacedSystemFont(ofSize: 14, weight: .regular),
NSAttributedString.Key.foregroundColor: UIColor.label
]))
crashReportTextView.attributedText = attributed
sendReportButton.layer.cornerRadius = 12.5
sendReportButton.layer.masksToBounds = true
sendReportButton.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(sendReportButtonLongPressed)))
}
private func updateSendReportButtonColor(lightened: Bool, animate: Bool) {
let color: UIColor
if lightened {
var hue: CGFloat = 0, saturation: CGFloat = 0, brightness: CGFloat = 0, alpha: CGFloat = 0
UIColor.systemBlue.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
color = UIColor(hue: hue, saturation: 0.85 * saturation, brightness: brightness, alpha: alpha)
} else {
color = .systemBlue
}
if animate {
UIView.animate(withDuration: 0.25) {
self.sendReportButton.backgroundColor = color
}
} else {
sendReportButton.backgroundColor = color
}
}
@IBAction func sendReportTouchDown(_ sender: Any) {
updateSendReportButtonColor(lightened: true, animate: false)
}
@IBAction func sendReportButtonTouchDragExit(_ sender: Any) {
updateSendReportButtonColor(lightened: false, animate: true)
}
@IBAction func sendReportButtonTouchDragEnter(_ sender: Any) {
updateSendReportButtonColor(lightened: true, animate: true)
}
@IBAction func sendReportTouchUpInside(_ sender: Any) {
updateSendReportButtonColor(lightened: false, animate: true)
let composeVC = MFMailComposeViewController()
composeVC.mailComposeDelegate = self
composeVC.setToRecipients(["me@shadowfacts.net"])
composeVC.setSubject(subject)
let data = reportText.data(using: .utf8)!
composeVC.addAttachmentData(data, mimeType: "text/plain", fileName: reportFilename)
self.present(composeVC, animated: true)
}
@objc func sendReportButtonLongPressed() {
let dir = FileManager.default.temporaryDirectory
let url = dir.appendingPathComponent(reportFilename)
try! reportText.data(using: .utf8)!.write(to: url)
let activityController = UIActivityViewController(activityItems: [url], applicationActivities: nil)
present(activityController, animated: true)
}
@IBAction func cancelPressed(_ sender: Any) {
delegate?.didDismissReporter()
}
}
extension IssueReporterViewController: MFMailComposeViewControllerDelegate {
func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
controller.dismiss(animated: true) {
self.delegate?.didDismissReporter()
}
}
}

View File

@ -1,13 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16097" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="CrashReporterViewController" customModule="Tusker" customModuleProvider="target">
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="IssueReporterViewController" customModule="Tusker" customModuleProvider="target">
<connections>
<outlet property="crashReportTextView" destination="hxN-7J-Usc" id="TGd-yq-Ds5"/>
<outlet property="sendReportButton" destination="Ofm-5l-nAp" id="6xM-hz-uvw"/>
@ -27,9 +29,9 @@
<subviews>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" verticalHuggingPriority="249" scrollEnabled="NO" editable="NO" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="hxN-7J-Usc">
<rect key="frame" x="0.0" y="0.0" width="414" height="166.5"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string>
<color key="textColor" systemColor="labelColor" cocoaTouchSystemColor="darkTextColor"/>
<color key="textColor" systemColor="labelColor"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
@ -43,13 +45,13 @@
<viewLayoutGuide key="contentLayoutGuide" id="LRh-7Z-mV1"/>
<viewLayoutGuide key="frameLayoutGuide" id="Rgd-t7-8QN"/>
</scrollView>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Ofm-5l-nAp">
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Ofm-5l-nAp">
<rect key="frame" x="52" y="730" width="310.5" height="50"/>
<color key="backgroundColor" systemColor="systemBlueColor" red="0.0" green="0.47843137250000001" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color key="backgroundColor" systemColor="systemBlueColor"/>
<constraints>
<constraint firstAttribute="height" constant="50" id="jHf-W0-qQn"/>
</constraints>
<state key="normal" title="Send Crash Report">
<state key="normal" title="Send Report">
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</state>
<connections>
@ -59,8 +61,8 @@
<action selector="sendReportTouchUpInside:" destination="-1" eventType="touchUpInside" id="ggd-fm-Orq"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="JiJ-Ng-jOz">
<rect key="frame" x="169" y="788" width="76" height="30"/>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="JiJ-Ng-jOz">
<rect key="frame" x="168.5" y="788" width="77" height="30"/>
<state key="normal" title="Don't Send"/>
<connections>
<action selector="cancelPressed:" destination="-1" eventType="touchUpInside" id="o4R-0Q-STS"/>
@ -69,7 +71,8 @@
</subviews>
</stackView>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<viewLayoutGuide key="safeArea" id="fnl-2z-Ty3"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstItem="uQy-Yw-Dba" firstAttribute="width" secondItem="i5M-Pr-FkT" secondAttribute="width" id="AX2-9e-cO0"/>
<constraint firstItem="fnl-2z-Ty3" firstAttribute="bottom" secondItem="a8U-KI-8PM" secondAttribute="bottom" id="Ec3-Px-dSW"/>
@ -79,8 +82,18 @@
<constraint firstItem="a8U-KI-8PM" firstAttribute="trailing" secondItem="fnl-2z-Ty3" secondAttribute="trailing" id="f59-qB-5T7"/>
<constraint firstItem="Ofm-5l-nAp" firstAttribute="width" secondItem="i5M-Pr-FkT" secondAttribute="width" multiplier="0.75" id="ueo-xb-Tfm"/>
</constraints>
<viewLayoutGuide key="safeArea" id="fnl-2z-Ty3"/>
<point key="canvasLocation" x="133" y="154"/>
</view>
</objects>
<resources>
<systemColor name="labelColor">
<color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
<systemColor name="systemBlueColor">
<color red="0.0" green="0.47843137254901963" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View File

@ -167,7 +167,7 @@ class InstanceSelectorTableViewController: UITableViewController {
switch response {
case let .failure(error):
self.showRecommendationsError(error)
case let .success(instances, _):
case let .success(instances):
self.recommendedInstances = instances
self.filterRecommendedResults()
}
@ -197,7 +197,7 @@ class InstanceSelectorTableViewController: UITableViewController {
tableView.tableHeaderView = header
}
private func showRecommendationsError(_ error: Client.Error) {
private func showRecommendationsError(_ error: Client.ErrorType) {
let footer = UITableViewHeaderFooterView()
footer.translatesAutoresizingMaskIntoConstraints = false

View File

@ -8,8 +8,10 @@
import UIKit
import AuthenticationServices
import Pachyderm
protocol OnboardingViewControllerDelegate {
@MainActor
func didFinishOnboarding(account: LocalData.UserAccountInfo)
}
@ -41,59 +43,109 @@ class OnboardingViewController: UINavigationController {
instanceSelector.delegate = self
}
@MainActor
private func tryLoginTo(instanceURL: URL) async throws {
let mastodonController = MastodonController(instanceURL: instanceURL)
let clientID: String
let clientSecret: String
do {
(clientID, clientSecret) = try await mastodonController.registerApp()
} catch {
throw Error.registeringApp(error)
}
let authCode = try await getAuthorizationCode(instanceURL: instanceURL, clientID: clientID)
let accessToken: String
do {
accessToken = try await mastodonController.authorize(authorizationCode: authCode)
} catch {
throw Error.gettingAccessToken(error)
}
// construct a temporary UserAccountInfo instance for the MastodonController to use to fetch its own account
let tempAccountInfo = LocalData.UserAccountInfo(id: "temp", instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: nil, accessToken: accessToken)
mastodonController.accountInfo = tempAccountInfo
let ownAccount: Account
do {
ownAccount = try await mastodonController.getOwnAccount()
} catch {
throw Error.gettingOwnAccount(error)
}
let accountInfo = LocalData.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: ownAccount.username, accessToken: accessToken)
mastodonController.accountInfo = accountInfo
self.onboardingDelegate?.didFinishOnboarding(account: accountInfo)
}
@MainActor
private func getAuthorizationCode(instanceURL: URL, clientID: String) async throws -> String {
var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)!
components.path = "/oauth/authorize"
components.queryItems = [
URLQueryItem(name: "client_id", value: clientID),
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "scope", value: "read write follow"),
URLQueryItem(name: "redirect_uri", value: "tusker://oauth")
]
let authorizeURL = components.url!
return try await withCheckedThrowingContinuation({ continuation in
self.authenticationSession = ASWebAuthenticationSession(url: authorizeURL, callbackURLScheme: "tusker", completionHandler: { url, error in
if let error = error {
continuation.resume(throwing: Error.authenticationSessionError(error))
} else if let url = url,
let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let item = components.queryItems?.first(where: { $0.name == "code" }),
let code = item.value {
continuation.resume(returning: code)
} else {
continuation.resume(throwing: Error.noAuthorizationCode)
}
})
// Prefer ephemeral sessions to make it easier to sign into multiple accounts on the same instance.
self.authenticationSession!.prefersEphemeralWebBrowserSession = true
self.authenticationSession!.presentationContextProvider = self
self.authenticationSession!.start()
})
}
}
extension OnboardingViewController {
enum Error: Swift.Error {
case registeringApp(Swift.Error)
case authenticationSessionError(Swift.Error)
case noAuthorizationCode
case gettingAccessToken(Swift.Error)
case gettingOwnAccount(Swift.Error)
var localizedDescription: String {
switch self {
case .registeringApp(let error):
return "Couldn't register app: \(error)"
case .authenticationSessionError(let error):
return error.localizedDescription
case .noAuthorizationCode:
return "No authorization code"
case .gettingAccessToken(let error):
return "Couldn't get access token: \(error)"
case .gettingOwnAccount(let error):
return "Couldn't fetch account: \(error)"
}
}
}
}
extension OnboardingViewController: InstanceSelectorTableViewControllerDelegate {
func didSelectInstance(url instanceURL: URL) {
let mastodonController = MastodonController(instanceURL: instanceURL)
mastodonController.registerApp { (clientID, clientSecret) in
var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)!
components.path = "/oauth/authorize"
components.queryItems = [
URLQueryItem(name: "client_id", value: clientID),
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "scope", value: "read write follow"),
URLQueryItem(name: "redirect_uri", value: "tusker://oauth")
]
let authorizeURL = components.url!
self.authenticationSession = ASWebAuthenticationSession(url: authorizeURL, callbackURLScheme: "tusker") { url, error in
guard error == nil,
let url = url,
let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let item = components.queryItems?.first(where: { $0.name == "code" }),
let authCode = item.value else { return }
mastodonController.authorize(authorizationCode: authCode) { (accessToken) in
// construct a temporary UserAccountInfo instance for the MastodonController to use to fetch it's own account
let tempAccountInfo = LocalData.UserAccountInfo(id: "temp", instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: nil, accessToken: accessToken)
mastodonController.accountInfo = tempAccountInfo
mastodonController.getOwnAccount { (result) in
DispatchQueue.main.async {
switch result {
case let .failure(error):
let alert = UIAlertController(title: "Unable to Verify Credentials", message: "Your account could not be fetched at this time: \(error.localizedDescription)", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil))
self.present(alert, animated: true)
case let .success(account):
// this needs to happen on the main thread because it publishes a new value for the ObservableObject
let accountInfo = LocalData.shared.addAccount(instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: account.username, accessToken: accessToken)
mastodonController.accountInfo = accountInfo
self.onboardingDelegate?.didFinishOnboarding(account: accountInfo)
}
}
}
}
}
DispatchQueue.main.async {
// Prefer ephemeral sessions to make it easier to sign into multiple accounts on the same instance.
self.authenticationSession!.prefersEphemeralWebBrowserSession = true
self.authenticationSession!.presentationContextProvider = self
self.authenticationSession!.start()
Task {
do {
try await self.tryLoginTo(instanceURL: instanceURL)
} catch let error as Error {
let alert = UIAlertController(title: "Error Logging In", message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Ok", style: .default))
self.present(alert, animated: true)
}
}
}

View File

@ -40,6 +40,11 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
super.viewDidLoad()
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: "statusCell")
// setup the initial snapshot with the sections in the right order, so we don't have to worry about order later
var snapshot = Snapshot()
snapshot.appendSections([.pinned, .statuses])
dataSource.apply(snapshot, animatingDifferences: false)
}
func updateUI(account: AccountMO) {
@ -72,6 +77,10 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
}
getStatuses { (response) in
guard self.state == .loadingInitial else {
return
}
switch response {
case let .failure(error):
completion(.failure(.client(error)))
@ -83,7 +92,6 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
DispatchQueue.main.async {
var snapshot = self.dataSource.snapshot()
snapshot.appendSections([.statuses])
snapshot.appendItems(statuses.map { Item(id: $0.id, state: .unknown) }, toSection: .statuses)
if self.kind == .statuses {
self.loadPinnedStatuses(snapshot: { snapshot }, completion: completion)
@ -110,10 +118,7 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
DispatchQueue.main.async {
var snapshot = snapshot()
if snapshot.indexOfSection(.pinned) != nil {
snapshot.deleteSections([.pinned])
}
snapshot.insertSections([.pinned], beforeSection: .statuses)
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .pinned))
snapshot.appendItems(statuses.map { Item(id: $0.id, state: .unknown) }, toSection: .pinned)
completion(.success(snapshot))
}
@ -209,7 +214,9 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
override func refresh() {
super.refresh()
if kind == .statuses {
// only refresh pinned if the super call actually succeded (put the state into .loadingNewer)
if state == .loadingNewer,
kind == .statuses {
loadPinnedStatuses(snapshot: dataSource.snapshot) { (result) in
switch result {
case .failure(_):
@ -240,6 +247,14 @@ extension ProfileStatusesViewController {
struct Item: Hashable {
let id: String
let state: StatusState
static func ==(lhs: Item, rhs: Item) -> Bool {
return lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
}

View File

@ -77,7 +77,9 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
let contentSections = snapshot.sectionIdentifiers.filter { timelineContentSections().contains($0) }
let contentSectionIndices = contentSections.compactMap(snapshot.indexOfSection(_:))
let maxContentSectionIndex = contentSectionIndices.max()!
guard let maxContentSectionIndex = contentSectionIndices.max() else {
return
}
if lastVisibleRow.section < maxContentSectionIndex {
return
@ -114,7 +116,7 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
case let .failure(.client(error)):
self.state = .unloaded
let config = ToastConfiguration(from: error, with: "Error Loading") { [weak self] (toast) in
let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] (toast) in
toast.dismissToast(animated: true)
self?.loadInitial()
}
@ -146,7 +148,7 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
self.dataSource.apply(snapshot, animatingDifferences: false)
case let .failure(.client(error)):
let config = ToastConfiguration(from: error, with: "Error Loading Older") { [weak self] (toast) in
let config = ToastConfiguration(from: error, with: "Error Loading Older", in: self) { [weak self] (toast) in
toast.dismissToast(animated: true)
self?.loadOlder()
}
@ -195,7 +197,12 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
// MARK: - RefreshableViewController
func refresh() {
guard state != .loadingNewer else { return }
// if we're unloaded, there's nothing "newer" to load
// if we're performing some other operation, we don't want to step on its toes
guard state == .loaded else {
self.refreshControl?.endRefreshing()
return
}
state = .loadingNewer
@ -229,7 +236,7 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
}
case let .failure(.client(error)):
let config = ToastConfiguration(from: error, with: "Error Loading Newer") { [weak self] (toast) in
let config = ToastConfiguration(from: error, with: "Error Loading Newer", in: self) { [weak self] (toast) in
toast.dismissToast(animated: true)
self?.refresh()
}

View File

@ -48,50 +48,21 @@ extension MenuPreviewProvider {
]
}
var actionsSection: [UIMenuElement] = [
let actionsSection: [UIMenuElement] = [
createAction(identifier: "sendmessage", title: "Send Message", systemImageName: "envelope", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.compose(mentioningAcct: account.acct)
}),
]
if accountID != mastodonController.account.id {
actionsSection.append(UIDeferredMenuElement({ (elementHandler) in
guard let mastodonController = self.mastodonController else {
elementHandler([])
return
}
let request = Client.getRelationships(accounts: [account.id])
// talk about callback hell :/
mastodonController.run(request) { [weak self] (response) in
guard let self = self,
case let .success(results, _) = response,
let relationship = results.first else {
DispatchQueue.main.async {
elementHandler([])
}
return
}
let following = relationship.following
DispatchQueue.main.async {
let action = self.createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.plus", handler: { (_) in
let request = (following ? Account.unfollow : Account.follow)(accountID)
mastodonController.run(request) { (response) in
switch response {
case .failure(_):
fatalError()
case let .success(relationship, _):
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
}
}
})
elementHandler([
action
])
UIDeferredMenuElement({ (elementHandler) in
Task { @MainActor in
if let action = await self.followAction(for: accountID, mastodonController: mastodonController) {
elementHandler([action])
} else {
elementHandler([])
}
}
}))
}
})
]
var shareSection = [
openInSafariAction(url: account.url),
@ -267,6 +238,30 @@ extension MenuPreviewProvider {
}
}
private func followAction(for accountID: String, mastodonController: MastodonController) async -> UIMenuElement? {
guard let ownAccount = try? await mastodonController.getOwnAccount(),
accountID != ownAccount.id else {
return nil
}
let request = Client.getRelationships(accounts: [accountID])
guard let (relationships, _) = try? await mastodonController.run(request),
let relationship = relationships.first else {
return nil
}
let following = relationship.following
return createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.plus") { _ in
let request = (following ? Account.unfollow : Account.follow)(accountID)
mastodonController.run(request) { response in
switch response {
case .failure(_):
fatalError()
case .success(let relationship, _):
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
}
}
}
}
}
extension LargeImageViewController: CustomPreviewPresenting {

View File

@ -36,7 +36,8 @@ class AccountTableViewCell: UITableViewCell {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
guard let account = mastodonController.persistentContainer.account(for: accountID) else {
fatalError("Missing cached account \(accountID!)")
// this table view cell could be cached in a table view (e.g., SearchResultsViewController) for an account that's since been purged
return
}
displayNameLabel.updateForAccountDisplayName(account: account)

View File

@ -10,6 +10,8 @@ import UIKit
import SwiftSoup
import Pachyderm
import SafariServices
import WebURL
import WebURLFoundationExtras
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
@ -100,7 +102,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
attributed.append(NSAttributedString(string: "\n"))
case "a":
if let link = try? node.attr("href"),
let url = URL(string: link) {
let webURL = WebURL(link),
let url = URL(webURL) {
attributed.addAttribute(.link, value: url, range: attributed.fullRange)
}
case "p":

View File

@ -16,6 +16,7 @@ struct ToastConfiguration {
var subtitle: String?
var actionTitle: String?
var action: ((ToastView) -> Void)?
var longPressAction: ((ToastView) -> Void)?
var edgeSpacing: CGFloat = 8
var edge: Edge = .automatic
var dismissOnScroll = true
@ -34,18 +35,29 @@ struct ToastConfiguration {
}
extension ToastConfiguration {
init(from error: Client.Error, with title: String, retryAction: @escaping (ToastView) -> Void) {
init(from error: Client.Error, with title: String, in viewController: UIViewController, retryAction: @escaping (ToastView) -> Void) {
self.init(title: title)
self.subtitle = error.localizedDescription
self.systemImageName = error.systemImageName
self.actionTitle = "Retry"
self.action = retryAction
self.longPressAction = { [unowned viewController] toast in
toast.dismissToast(animated: true)
let text = """
\(title):
\(error.requestMethod.name) \(error.requestEndpoint)
\(error.type)
"""
let reporter = IssueReporterViewController.create(reportText: text, delegate: viewController)
viewController.present(reporter, animated: true)
}
}
}
fileprivate extension Client.Error {
var systemImageName: String {
switch self {
switch type {
case .networkError(_):
return "wifi.exclamationmark"
default:
@ -53,3 +65,10 @@ fileprivate extension Client.Error {
}
}
}
// todo: i don't like that this protocol conformance is accessible outside of this file
extension UIViewController: IssueReporterViewControllerDelegate {
func didDismissReporter() {
self.dismiss(animated: true)
}
}

View File

@ -14,6 +14,7 @@ class ToastView: UIView {
private var shrinkAnimator: UIViewPropertyAnimator?
private var recognizedGesture = false
private var handledLongPress = false
private var shouldDismissOnScroll = false
private(set) var shouldDismissAutomatically = true
@ -102,6 +103,8 @@ class ToastView: UIView {
let pan = UIPanGestureRecognizer(target: self, action: #selector(panRecognized))
addGestureRecognizer(pan)
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(longPressRecognized))
addGestureRecognizer(longPress)
}
override func layoutSubviews() {
@ -154,6 +157,8 @@ class ToastView: UIView {
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
handledLongPress = false
if !recognizedGesture {
guard let shrinkAnimator = shrinkAnimator else {
return
@ -252,4 +257,12 @@ class ToastView: UIView {
}
}
@objc private func longPressRecognized() {
guard !handledLongPress else {
return
}
configuration.longPressAction?(self)
handledLongPress = true
}
}