Compare commits
10 Commits
930ec7ccff
...
7da139be4d
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 7da139be4d | |
Shadowfacts | 2444783edf | |
Shadowfacts | 727615a818 | |
Shadowfacts | 6e3089f025 | |
Shadowfacts | e09b0ff4e3 | |
Shadowfacts | 830eea5e95 | |
Shadowfacts | 705fbbe343 | |
Shadowfacts | 12bcf52764 | |
Shadowfacts | f31c909517 | |
Shadowfacts | 781c37fbae |
|
@ -68,29 +68,32 @@ public class Client {
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func run<Result>(_ request: Request<Result>, completion: @escaping Callback<Result>) -> URLSessionTask? {
|
public func run<Result>(_ request: Request<Result>, completion: @escaping Callback<Result>) -> URLSessionTask? {
|
||||||
guard let request = createURLRequest(request: request) else {
|
guard let urlRequest = createURLRequest(request: request) else {
|
||||||
completion(.failure(Error.invalidRequest))
|
completion(.failure(Error(request: request, type: .invalidRequest)))
|
||||||
return nil
|
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 {
|
if let error = error {
|
||||||
completion(.failure(.networkError(error)))
|
completion(.failure(Error(request: request, type: .networkError(error))))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let data = data,
|
guard let data = data,
|
||||||
let response = response as? HTTPURLResponse else {
|
let response = response as? HTTPURLResponse else {
|
||||||
completion(.failure(.invalidResponse))
|
completion(.failure(Error(request: request, type: .invalidResponse)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard response.statusCode == 200 else {
|
guard response.statusCode == 200 else {
|
||||||
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
|
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
|
||||||
let error: Error = mastodonError.flatMap { .mastodonError($0.description) } ?? .unexpectedStatus(response.statusCode)
|
let type: ErrorType = mastodonError.flatMap { .mastodonError($0.description) } ?? .unexpectedStatus(response.statusCode)
|
||||||
completion(.failure(error))
|
completion(.failure(Error(request: request, type: type)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let result = try? Client.decoder.decode(Result.self, from: data) else {
|
let result: Result
|
||||||
completion(.failure(.invalidModel))
|
do {
|
||||||
|
result = try Client.decoder.decode(Result.self, from: data)
|
||||||
|
} catch {
|
||||||
|
completion(.failure(Error(request: request, type: .invalidModel(error))))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
|
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? {
|
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
|
||||||
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
|
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
|
components.queryItems = request.queryParameters.isEmpty ? nil : request.queryParameters.queryItems
|
||||||
guard let url = components.url else { return nil }
|
guard let url = components.url else { return nil }
|
||||||
var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval)
|
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" }),
|
if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
|
||||||
let components = URLComponents(string: url.href),
|
let components = URLComponents(string: url.href),
|
||||||
components.host == self.baseURL.host {
|
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)
|
self.run(nodeInfo, completion: completion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -392,16 +395,19 @@ public class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Client {
|
extension Client {
|
||||||
public enum Error: LocalizedError {
|
public struct Error: LocalizedError {
|
||||||
case networkError(Swift.Error)
|
public let requestMethod: Method
|
||||||
case unexpectedStatus(Int)
|
public let requestEndpoint: Endpoint
|
||||||
case invalidRequest
|
public let type: ErrorType
|
||||||
case invalidResponse
|
|
||||||
case invalidModel
|
init<ResultType: Decodable>(request: Request<ResultType>, type: ErrorType) {
|
||||||
case mastodonError(String)
|
self.requestMethod = request.method
|
||||||
|
self.requestEndpoint = request.endpoint
|
||||||
|
self.type = type
|
||||||
|
}
|
||||||
|
|
||||||
public var localizedDescription: String {
|
public var localizedDescription: String {
|
||||||
switch self {
|
switch type {
|
||||||
case .networkError(let error):
|
case .networkError(let error):
|
||||||
return "Network Error: \(error.localizedDescription)"
|
return "Network Error: \(error.localizedDescription)"
|
||||||
// todo: support more status codes
|
// todo: support more status codes
|
||||||
|
@ -413,11 +419,19 @@ extension Client {
|
||||||
return "Invalid Request"
|
return "Invalid Request"
|
||||||
case .invalidResponse:
|
case .invalidResponse:
|
||||||
return "Invalid Response"
|
return "Invalid Response"
|
||||||
case .invalidModel:
|
case .invalidModel(_):
|
||||||
return "Invalid Model"
|
return "Invalid Model"
|
||||||
case .mastodonError(let error):
|
case .mastodonError(let error):
|
||||||
return "Server Error: \(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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,7 @@ public enum Timeline {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Timeline {
|
extension Timeline {
|
||||||
var endpoint: String {
|
var endpoint: Endpoint {
|
||||||
switch self {
|
switch self {
|
||||||
case .home:
|
case .home:
|
||||||
return "/api/v1/timelines/home"
|
return "/api/v1/timelines/home"
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -8,12 +8,12 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum Method {
|
public enum Method {
|
||||||
case get, post, put, patch, delete
|
case get, post, put, patch, delete
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Method {
|
extension Method {
|
||||||
var name: String {
|
public var name: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .get:
|
case .get:
|
||||||
return "GET"
|
return "GET"
|
||||||
|
|
|
@ -10,13 +10,13 @@ import Foundation
|
||||||
|
|
||||||
public struct Request<ResultType: Decodable> {
|
public struct Request<ResultType: Decodable> {
|
||||||
let method: Method
|
let method: Method
|
||||||
let path: String
|
let endpoint: Endpoint
|
||||||
let body: Body
|
let body: Body
|
||||||
var queryParameters: [Parameter]
|
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.method = method
|
||||||
self.path = path
|
self.endpoint = path
|
||||||
self.body = body
|
self.body = body
|
||||||
self.queryParameters = queryParameters
|
self.queryParameters = queryParameters
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,7 @@ public class InstanceSelector {
|
||||||
|
|
||||||
private static let decoder = JSONDecoder()
|
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
|
let url: URL
|
||||||
if let category = category {
|
if let category = category {
|
||||||
url = URL(string: "https://api.joinmastodon.org/servers?category=\(category)")!
|
url = URL(string: "https://api.joinmastodon.org/servers?category=\(category)")!
|
||||||
|
@ -34,11 +34,14 @@ public class InstanceSelector {
|
||||||
completion(.failure(.unexpectedStatus(response.statusCode)))
|
completion(.failure(.unexpectedStatus(response.statusCode)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let result = try? decoder.decode([Instance].self, from: data) else {
|
let result: [Instance]
|
||||||
completion(.failure(Client.Error.invalidModel))
|
do {
|
||||||
|
result = try decoder.decode([Instance].self, from: data)
|
||||||
|
} catch {
|
||||||
|
completion(.failure(.invalidModel(error)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
completion(.success(result, nil))
|
completion(.success(result))
|
||||||
}
|
}
|
||||||
task.resume()
|
task.resume()
|
||||||
}
|
}
|
||||||
|
|
|
@ -69,6 +69,8 @@
|
||||||
D6109A0D214599E100432DC2 /* RequestRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6109A0C214599E100432DC2 /* RequestRange.swift */; };
|
D6109A0D214599E100432DC2 /* RequestRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6109A0C214599E100432DC2 /* RequestRange.swift */; };
|
||||||
D6109A0F21459B6900432DC2 /* Pagination.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6109A0E21459B6900432DC2 /* Pagination.swift */; };
|
D6109A0F21459B6900432DC2 /* Pagination.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6109A0E21459B6900432DC2 /* Pagination.swift */; };
|
||||||
D6109A11214607D500432DC2 /* Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6109A10214607D500432DC2 /* Timeline.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 */; };
|
D611C2CF232DC61100C86A49 /* HashtagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */; };
|
||||||
D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */; };
|
D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */; };
|
||||||
D61AC1D3232E928600C54D2D /* InstanceSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D2232E928600C54D2D /* InstanceSelector.swift */; };
|
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 */; };
|
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; };
|
||||||
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; };
|
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; };
|
||||||
D6F1F9DF27B0613300CB7D88 /* WebURL in Frameworks */ = {isa = PBXBuildFile; productRef = D6F1F9DE27B0613300CB7D88 /* WebURL */; settings = {ATTRIBUTES = (Required, ); }; };
|
D6F1F9DF27B0613300CB7D88 /* WebURL in Frameworks */ = {isa = PBXBuildFile; productRef = D6F1F9DE27B0613300CB7D88 /* WebURL */; settings = {ATTRIBUTES = (Required, ); }; };
|
||||||
D6F2E965249E8BFD005846BB /* CrashReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* CrashReporterViewController.swift */; };
|
D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; };
|
||||||
D6F2E966249E8BFD005846BB /* CrashReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* CrashReporterViewController.xib */; };
|
D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; };
|
||||||
D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F953EF21251A2900CF0F2B /* MastodonController.swift */; };
|
D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F953EF21251A2900CF0F2B /* MastodonController.swift */; };
|
||||||
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */; };
|
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
@ -480,6 +482,8 @@
|
||||||
D6109A0C214599E100432DC2 /* RequestRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestRange.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReporterViewController.swift; sourceTree = "<group>"; };
|
||||||
D6F2E964249E8BFD005846BB /* CrashReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CrashReporterViewController.xib; 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>"; };
|
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>"; };
|
D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSwitchingContainerViewController.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
@ -872,6 +876,7 @@
|
||||||
D61099D12144B2E600432DC2 /* Body.swift */,
|
D61099D12144B2E600432DC2 /* Body.swift */,
|
||||||
D61099D32144B32E00432DC2 /* Parameter.swift */,
|
D61099D32144B32E00432DC2 /* Parameter.swift */,
|
||||||
D61099D52144B4B200432DC2 /* FormAttachment.swift */,
|
D61099D52144B4B200432DC2 /* FormAttachment.swift */,
|
||||||
|
D6114E0A27F3F6EA0080E273 /* Endpoint.swift */,
|
||||||
);
|
);
|
||||||
path = Request;
|
path = Request;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1681,8 +1686,9 @@
|
||||||
D6F2E960249E772F005846BB /* Crash Reporter */ = {
|
D6F2E960249E772F005846BB /* Crash Reporter */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D6F2E963249E8BFD005846BB /* CrashReporterViewController.swift */,
|
D6114E0827F3EA3D0080E273 /* CrashReporterViewController.swift */,
|
||||||
D6F2E964249E8BFD005846BB /* CrashReporterViewController.xib */,
|
D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */,
|
||||||
|
D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */,
|
||||||
);
|
);
|
||||||
path = "Crash Reporter";
|
path = "Crash Reporter";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1950,7 +1956,7 @@
|
||||||
D64BC19023C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib in Resources */,
|
D64BC19023C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib in Resources */,
|
||||||
D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */,
|
D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */,
|
||||||
D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */,
|
D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */,
|
||||||
D6F2E966249E8BFD005846BB /* CrashReporterViewController.xib in Resources */,
|
D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */,
|
||||||
D662AEF0263A3B880082A153 /* PollFinishedTableViewCell.xib in Resources */,
|
D662AEF0263A3B880082A153 /* PollFinishedTableViewCell.xib in Resources */,
|
||||||
D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */,
|
D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */,
|
||||||
D6DEA0DF268400C300FE896A /* ConfirmLoadMoreTableViewCell.xib in Resources */,
|
D6DEA0DF268400C300FE896A /* ConfirmLoadMoreTableViewCell.xib in Resources */,
|
||||||
|
@ -2070,6 +2076,7 @@
|
||||||
D61099DC2144BDBF00432DC2 /* Response.swift in Sources */,
|
D61099DC2144BDBF00432DC2 /* Response.swift in Sources */,
|
||||||
D61099F72145693500432DC2 /* PushSubscription.swift in Sources */,
|
D61099F72145693500432DC2 /* PushSubscription.swift in Sources */,
|
||||||
D61099F5214568C300432DC2 /* Notification.swift in Sources */,
|
D61099F5214568C300432DC2 /* Notification.swift in Sources */,
|
||||||
|
D6114E0B27F3F6EA0080E273 /* Endpoint.swift in Sources */,
|
||||||
D61099EF214566C000432DC2 /* Instance.swift in Sources */,
|
D61099EF214566C000432DC2 /* Instance.swift in Sources */,
|
||||||
D61099D22144B2E600432DC2 /* Body.swift in Sources */,
|
D61099D22144B2E600432DC2 /* Body.swift in Sources */,
|
||||||
D623A53F2635F6910095BD04 /* Poll.swift in Sources */,
|
D623A53F2635F6910095BD04 /* Poll.swift in Sources */,
|
||||||
|
@ -2144,7 +2151,7 @@
|
||||||
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */,
|
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */,
|
||||||
D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */,
|
D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */,
|
||||||
D620483623D38075008A63EF /* ContentTextView.swift in Sources */,
|
D620483623D38075008A63EF /* ContentTextView.swift in Sources */,
|
||||||
D6F2E965249E8BFD005846BB /* CrashReporterViewController.swift in Sources */,
|
D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */,
|
||||||
D6A57408255C53EC00674551 /* ComposeTextViewCaretScrolling.swift in Sources */,
|
D6A57408255C53EC00674551 /* ComposeTextViewCaretScrolling.swift in Sources */,
|
||||||
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */,
|
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */,
|
||||||
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */,
|
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */,
|
||||||
|
@ -2318,6 +2325,7 @@
|
||||||
D677284E24ECC01D00C732D3 /* Draft.swift in Sources */,
|
D677284E24ECC01D00C732D3 /* Draft.swift in Sources */,
|
||||||
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
|
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
|
||||||
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,
|
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,
|
||||||
|
D6114E0927F3EA3D0080E273 /* CrashReporterViewController.swift in Sources */,
|
||||||
D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */,
|
D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */,
|
||||||
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
|
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
|
||||||
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
|
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
|
||||||
|
|
|
@ -67,28 +67,54 @@ class MastodonController: ObservableObject {
|
||||||
return client.run(request, completion: completion)
|
return client.run(request, completion: completion)
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerApp(completion: @escaping (_ clientID: String, _ clientSecret: String) -> Void) {
|
func run<Result>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
|
||||||
guard client.clientID == nil,
|
return try await withCheckedThrowingContinuation({ continuation in
|
||||||
client.clientSecret == nil else {
|
client.run(request) { response in
|
||||||
|
switch response {
|
||||||
completion(client.clientID!, client.clientSecret!)
|
case .failure(let error):
|
||||||
return
|
continuation.resume(throwing: error)
|
||||||
}
|
case .success(let result, let pagination):
|
||||||
|
continuation.resume(returning: (result, pagination))
|
||||||
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.clientID = app.clientID
|
||||||
self.client.clientSecret = app.clientSecret
|
self.client.clientSecret = app.clientSecret
|
||||||
completion(app.clientID, app.clientSecret)
|
return (app.clientID, app.clientSecret)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func authorize(authorizationCode: String, completion: @escaping (_ accessToken: String) -> Void) {
|
/// - Returns: The access token
|
||||||
client.getAccessToken(authorizationCode: authorizationCode, redirectURI: "tusker://oauth") { response in
|
func authorize(authorizationCode: String) async throws -> String {
|
||||||
guard case let .success(settings, _) = response else { fatalError() }
|
return try await withCheckedThrowingContinuation({ continuation in
|
||||||
self.client.accessToken = settings.accessToken
|
client.getAccessToken(authorizationCode: authorizationCode, redirectURI: "tusker://oauth") { response in
|
||||||
completion(settings.accessToken)
|
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) {
|
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) {
|
func getOwnInstance(completion: ((Instance) -> Void)? = nil) {
|
||||||
getOwnInstanceInternal(retryAttempt: 0, completion: completion)
|
getOwnInstanceInternal(retryAttempt: 0, completion: completion)
|
||||||
}
|
}
|
||||||
|
|
|
@ -127,7 +127,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
window!.rootViewController = CrashReporterViewController.create(report: report)
|
window!.rootViewController = CrashReporterViewController.create(report: report, delegate: self)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -208,10 +208,8 @@ extension MainSceneDelegate: OnboardingViewControllerDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension MainSceneDelegate: MFMailComposeViewControllerDelegate {
|
extension MainSceneDelegate: IssueReporterViewControllerDelegate {
|
||||||
func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
|
func didDismissReporter() {
|
||||||
controller.dismiss(animated: true) {
|
showAppOrOnboardingUI()
|
||||||
self.showAppOrOnboardingUI()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -143,7 +143,9 @@ class AssetCollectionViewController: UIViewController, UICollectionViewDelegate
|
||||||
switch PHPhotoLibrary.authorizationStatus(for: .readWrite) {
|
switch PHPhotoLibrary.authorizationStatus(for: .readWrite) {
|
||||||
case .notDetermined:
|
case .notDetermined:
|
||||||
PHPhotoLibrary.requestAuthorization(for: .readWrite) { (_) in
|
PHPhotoLibrary.requestAuthorization(for: .readWrite) { (_) in
|
||||||
self.loadAssets()
|
DispatchQueue.main.async {
|
||||||
|
self.loadAssets()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
@ -157,7 +157,7 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.loadingState = .unloaded
|
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)
|
toast.dismissToast(animated: true)
|
||||||
self?.loadMainStatus()
|
self?.loadMainStatus()
|
||||||
}
|
}
|
||||||
|
@ -210,7 +210,7 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.loadingState = .loadedMain
|
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)
|
toast.dismissToast(animated: true)
|
||||||
self?.loadContext(for: mainStatus)
|
self?.loadContext(for: mainStatus)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,132 +2,46 @@
|
||||||
// CrashReporterViewController.swift
|
// CrashReporterViewController.swift
|
||||||
// Tusker
|
// Tusker
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 6/20/20.
|
// Created by Shadowfacts on 3/29/22.
|
||||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import CrashReporter
|
import CrashReporter
|
||||||
import MessageUI
|
|
||||||
|
|
||||||
class CrashReporterViewController: UIViewController {
|
|
||||||
|
|
||||||
|
class CrashReporterViewController: IssueReporterViewController {
|
||||||
|
|
||||||
private let report: PLCrashReport
|
private let report: PLCrashReport
|
||||||
private var reportText: String!
|
|
||||||
|
|
||||||
private var reportFilename: String {
|
override var preamble: String {
|
||||||
let timestamp = ISO8601DateFormatter().string(from: report.systemInfo.timestamp)
|
"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."
|
||||||
return "Tusker-crash-\(timestamp).crash"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@IBOutlet weak var crashReportTextView: UITextView!
|
override var subject: String {
|
||||||
@IBOutlet weak var sendReportButton: UIButton!
|
"Tusker Crash Report"
|
||||||
|
|
||||||
static func create(report: PLCrashReport) -> UINavigationController {
|
|
||||||
let nav = UINavigationController(rootViewController: CrashReporterViewController(report: report))
|
|
||||||
nav.navigationBar.prefersLargeTitles = true
|
|
||||||
return nav
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
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) {
|
required init?(coder: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
navigationItem.title = NSLocalizedString("Crash Detected", comment: "crash reporter title")
|
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,13 +1,15 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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"/>
|
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||||
<dependencies>
|
<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="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"/>
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<objects>
|
<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>
|
<connections>
|
||||||
<outlet property="crashReportTextView" destination="hxN-7J-Usc" id="TGd-yq-Ds5"/>
|
<outlet property="crashReportTextView" destination="hxN-7J-Usc" id="TGd-yq-Ds5"/>
|
||||||
<outlet property="sendReportButton" destination="Ofm-5l-nAp" id="6xM-hz-uvw"/>
|
<outlet property="sendReportButton" destination="Ofm-5l-nAp" id="6xM-hz-uvw"/>
|
||||||
|
@ -27,9 +29,9 @@
|
||||||
<subviews>
|
<subviews>
|
||||||
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" verticalHuggingPriority="249" scrollEnabled="NO" editable="NO" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="hxN-7J-Usc">
|
<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"/>
|
<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>
|
<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"/>
|
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
||||||
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
||||||
</textView>
|
</textView>
|
||||||
|
@ -43,13 +45,13 @@
|
||||||
<viewLayoutGuide key="contentLayoutGuide" id="LRh-7Z-mV1"/>
|
<viewLayoutGuide key="contentLayoutGuide" id="LRh-7Z-mV1"/>
|
||||||
<viewLayoutGuide key="frameLayoutGuide" id="Rgd-t7-8QN"/>
|
<viewLayoutGuide key="frameLayoutGuide" id="Rgd-t7-8QN"/>
|
||||||
</scrollView>
|
</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"/>
|
<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>
|
<constraints>
|
||||||
<constraint firstAttribute="height" constant="50" id="jHf-W0-qQn"/>
|
<constraint firstAttribute="height" constant="50" id="jHf-W0-qQn"/>
|
||||||
</constraints>
|
</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"/>
|
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
</state>
|
</state>
|
||||||
<connections>
|
<connections>
|
||||||
|
@ -59,8 +61,8 @@
|
||||||
<action selector="sendReportTouchUpInside:" destination="-1" eventType="touchUpInside" id="ggd-fm-Orq"/>
|
<action selector="sendReportTouchUpInside:" destination="-1" eventType="touchUpInside" id="ggd-fm-Orq"/>
|
||||||
</connections>
|
</connections>
|
||||||
</button>
|
</button>
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="JiJ-Ng-jOz">
|
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="JiJ-Ng-jOz">
|
||||||
<rect key="frame" x="169" y="788" width="76" height="30"/>
|
<rect key="frame" x="168.5" y="788" width="77" height="30"/>
|
||||||
<state key="normal" title="Don't Send"/>
|
<state key="normal" title="Don't Send"/>
|
||||||
<connections>
|
<connections>
|
||||||
<action selector="cancelPressed:" destination="-1" eventType="touchUpInside" id="o4R-0Q-STS"/>
|
<action selector="cancelPressed:" destination="-1" eventType="touchUpInside" id="o4R-0Q-STS"/>
|
||||||
|
@ -69,7 +71,8 @@
|
||||||
</subviews>
|
</subviews>
|
||||||
</stackView>
|
</stackView>
|
||||||
</subviews>
|
</subviews>
|
||||||
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
<viewLayoutGuide key="safeArea" id="fnl-2z-Ty3"/>
|
||||||
|
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="uQy-Yw-Dba" firstAttribute="width" secondItem="i5M-Pr-FkT" secondAttribute="width" id="AX2-9e-cO0"/>
|
<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"/>
|
<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="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"/>
|
<constraint firstItem="Ofm-5l-nAp" firstAttribute="width" secondItem="i5M-Pr-FkT" secondAttribute="width" multiplier="0.75" id="ueo-xb-Tfm"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
<viewLayoutGuide key="safeArea" id="fnl-2z-Ty3"/>
|
|
||||||
<point key="canvasLocation" x="133" y="154"/>
|
<point key="canvasLocation" x="133" y="154"/>
|
||||||
</view>
|
</view>
|
||||||
</objects>
|
</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>
|
</document>
|
|
@ -167,7 +167,7 @@ class InstanceSelectorTableViewController: UITableViewController {
|
||||||
switch response {
|
switch response {
|
||||||
case let .failure(error):
|
case let .failure(error):
|
||||||
self.showRecommendationsError(error)
|
self.showRecommendationsError(error)
|
||||||
case let .success(instances, _):
|
case let .success(instances):
|
||||||
self.recommendedInstances = instances
|
self.recommendedInstances = instances
|
||||||
self.filterRecommendedResults()
|
self.filterRecommendedResults()
|
||||||
}
|
}
|
||||||
|
@ -197,7 +197,7 @@ class InstanceSelectorTableViewController: UITableViewController {
|
||||||
tableView.tableHeaderView = header
|
tableView.tableHeaderView = header
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showRecommendationsError(_ error: Client.Error) {
|
private func showRecommendationsError(_ error: Client.ErrorType) {
|
||||||
let footer = UITableViewHeaderFooterView()
|
let footer = UITableViewHeaderFooterView()
|
||||||
footer.translatesAutoresizingMaskIntoConstraints = false
|
footer.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
|
|
@ -8,8 +8,10 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import AuthenticationServices
|
import AuthenticationServices
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
protocol OnboardingViewControllerDelegate {
|
protocol OnboardingViewControllerDelegate {
|
||||||
|
@MainActor
|
||||||
func didFinishOnboarding(account: LocalData.UserAccountInfo)
|
func didFinishOnboarding(account: LocalData.UserAccountInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,60 +42,110 @@ class OnboardingViewController: UINavigationController {
|
||||||
|
|
||||||
instanceSelector.delegate = self
|
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 {
|
extension OnboardingViewController: InstanceSelectorTableViewControllerDelegate {
|
||||||
func didSelectInstance(url instanceURL: URL) {
|
func didSelectInstance(url instanceURL: URL) {
|
||||||
let mastodonController = MastodonController(instanceURL: instanceURL)
|
Task {
|
||||||
mastodonController.registerApp { (clientID, clientSecret) in
|
do {
|
||||||
|
try await self.tryLoginTo(instanceURL: instanceURL)
|
||||||
var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)!
|
} catch let error as Error {
|
||||||
components.path = "/oauth/authorize"
|
let alert = UIAlertController(title: "Error Logging In", message: error.localizedDescription, preferredStyle: .alert)
|
||||||
components.queryItems = [
|
alert.addAction(UIAlertAction(title: "Ok", style: .default))
|
||||||
URLQueryItem(name: "client_id", value: clientID),
|
self.present(alert, animated: true)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,6 +40,11 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: "statusCell")
|
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) {
|
func updateUI(account: AccountMO) {
|
||||||
|
@ -72,6 +77,10 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
|
||||||
}
|
}
|
||||||
|
|
||||||
getStatuses { (response) in
|
getStatuses { (response) in
|
||||||
|
guard self.state == .loadingInitial else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch response {
|
switch response {
|
||||||
case let .failure(error):
|
case let .failure(error):
|
||||||
completion(.failure(.client(error)))
|
completion(.failure(.client(error)))
|
||||||
|
@ -83,7 +92,6 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
|
||||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
var snapshot = self.dataSource.snapshot()
|
var snapshot = self.dataSource.snapshot()
|
||||||
snapshot.appendSections([.statuses])
|
|
||||||
snapshot.appendItems(statuses.map { Item(id: $0.id, state: .unknown) }, toSection: .statuses)
|
snapshot.appendItems(statuses.map { Item(id: $0.id, state: .unknown) }, toSection: .statuses)
|
||||||
if self.kind == .statuses {
|
if self.kind == .statuses {
|
||||||
self.loadPinnedStatuses(snapshot: { snapshot }, completion: completion)
|
self.loadPinnedStatuses(snapshot: { snapshot }, completion: completion)
|
||||||
|
@ -110,10 +118,7 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
|
||||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
var snapshot = snapshot()
|
var snapshot = snapshot()
|
||||||
if snapshot.indexOfSection(.pinned) != nil {
|
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .pinned))
|
||||||
snapshot.deleteSections([.pinned])
|
|
||||||
}
|
|
||||||
snapshot.insertSections([.pinned], beforeSection: .statuses)
|
|
||||||
snapshot.appendItems(statuses.map { Item(id: $0.id, state: .unknown) }, toSection: .pinned)
|
snapshot.appendItems(statuses.map { Item(id: $0.id, state: .unknown) }, toSection: .pinned)
|
||||||
completion(.success(snapshot))
|
completion(.success(snapshot))
|
||||||
}
|
}
|
||||||
|
@ -209,7 +214,9 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
|
||||||
override func refresh() {
|
override func refresh() {
|
||||||
super.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
|
loadPinnedStatuses(snapshot: dataSource.snapshot) { (result) in
|
||||||
switch result {
|
switch result {
|
||||||
case .failure(_):
|
case .failure(_):
|
||||||
|
@ -240,6 +247,14 @@ extension ProfileStatusesViewController {
|
||||||
struct Item: Hashable {
|
struct Item: Hashable {
|
||||||
let id: String
|
let id: String
|
||||||
let state: StatusState
|
let state: StatusState
|
||||||
|
|
||||||
|
static func ==(lhs: Item, rhs: Item) -> Bool {
|
||||||
|
return lhs.id == rhs.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -77,7 +77,9 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
|
||||||
|
|
||||||
let contentSections = snapshot.sectionIdentifiers.filter { timelineContentSections().contains($0) }
|
let contentSections = snapshot.sectionIdentifiers.filter { timelineContentSections().contains($0) }
|
||||||
let contentSectionIndices = contentSections.compactMap(snapshot.indexOfSection(_:))
|
let contentSectionIndices = contentSections.compactMap(snapshot.indexOfSection(_:))
|
||||||
let maxContentSectionIndex = contentSectionIndices.max()!
|
guard let maxContentSectionIndex = contentSectionIndices.max() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if lastVisibleRow.section < maxContentSectionIndex {
|
if lastVisibleRow.section < maxContentSectionIndex {
|
||||||
return
|
return
|
||||||
|
@ -114,7 +116,7 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
|
||||||
|
|
||||||
case let .failure(.client(error)):
|
case let .failure(.client(error)):
|
||||||
self.state = .unloaded
|
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)
|
toast.dismissToast(animated: true)
|
||||||
self?.loadInitial()
|
self?.loadInitial()
|
||||||
}
|
}
|
||||||
|
@ -146,7 +148,7 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
|
||||||
self.dataSource.apply(snapshot, animatingDifferences: false)
|
self.dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
|
||||||
case let .failure(.client(error)):
|
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)
|
toast.dismissToast(animated: true)
|
||||||
self?.loadOlder()
|
self?.loadOlder()
|
||||||
}
|
}
|
||||||
|
@ -195,7 +197,12 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
|
||||||
// MARK: - RefreshableViewController
|
// MARK: - RefreshableViewController
|
||||||
|
|
||||||
func refresh() {
|
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
|
state = .loadingNewer
|
||||||
|
|
||||||
|
@ -229,7 +236,7 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
|
||||||
}
|
}
|
||||||
|
|
||||||
case let .failure(.client(error)):
|
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)
|
toast.dismissToast(animated: true)
|
||||||
self?.refresh()
|
self?.refresh()
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,50 +48,21 @@ extension MenuPreviewProvider {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
var actionsSection: [UIMenuElement] = [
|
let actionsSection: [UIMenuElement] = [
|
||||||
createAction(identifier: "sendmessage", title: "Send Message", systemImageName: "envelope", handler: { [weak self] (_) in
|
createAction(identifier: "sendmessage", title: "Send Message", systemImageName: "envelope", handler: { [weak self] (_) in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.navigationDelegate?.compose(mentioningAcct: account.acct)
|
self.navigationDelegate?.compose(mentioningAcct: account.acct)
|
||||||
}),
|
}),
|
||||||
]
|
UIDeferredMenuElement({ (elementHandler) in
|
||||||
|
Task { @MainActor in
|
||||||
if accountID != mastodonController.account.id {
|
if let action = await self.followAction(for: accountID, mastodonController: mastodonController) {
|
||||||
actionsSection.append(UIDeferredMenuElement({ (elementHandler) in
|
elementHandler([action])
|
||||||
guard let mastodonController = self.mastodonController else {
|
} else {
|
||||||
elementHandler([])
|
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
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}))
|
})
|
||||||
}
|
]
|
||||||
|
|
||||||
var shareSection = [
|
var shareSection = [
|
||||||
openInSafariAction(url: account.url),
|
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 {
|
extension LargeImageViewController: CustomPreviewPresenting {
|
||||||
|
|
|
@ -36,7 +36,8 @@ class AccountTableViewCell: UITableViewCell {
|
||||||
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
|
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
|
||||||
|
|
||||||
guard let account = mastodonController.persistentContainer.account(for: accountID) else {
|
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)
|
displayNameLabel.updateForAccountDisplayName(account: account)
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,8 @@ import UIKit
|
||||||
import SwiftSoup
|
import SwiftSoup
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import SafariServices
|
import SafariServices
|
||||||
|
import WebURL
|
||||||
|
import WebURLFoundationExtras
|
||||||
|
|
||||||
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
||||||
|
|
||||||
|
@ -100,7 +102,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||||
attributed.append(NSAttributedString(string: "\n"))
|
attributed.append(NSAttributedString(string: "\n"))
|
||||||
case "a":
|
case "a":
|
||||||
if let link = try? node.attr("href"),
|
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)
|
attributed.addAttribute(.link, value: url, range: attributed.fullRange)
|
||||||
}
|
}
|
||||||
case "p":
|
case "p":
|
||||||
|
|
|
@ -16,6 +16,7 @@ struct ToastConfiguration {
|
||||||
var subtitle: String?
|
var subtitle: String?
|
||||||
var actionTitle: String?
|
var actionTitle: String?
|
||||||
var action: ((ToastView) -> Void)?
|
var action: ((ToastView) -> Void)?
|
||||||
|
var longPressAction: ((ToastView) -> Void)?
|
||||||
var edgeSpacing: CGFloat = 8
|
var edgeSpacing: CGFloat = 8
|
||||||
var edge: Edge = .automatic
|
var edge: Edge = .automatic
|
||||||
var dismissOnScroll = true
|
var dismissOnScroll = true
|
||||||
|
@ -34,18 +35,29 @@ struct ToastConfiguration {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension 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.init(title: title)
|
||||||
self.subtitle = error.localizedDescription
|
self.subtitle = error.localizedDescription
|
||||||
self.systemImageName = error.systemImageName
|
self.systemImageName = error.systemImageName
|
||||||
self.actionTitle = "Retry"
|
self.actionTitle = "Retry"
|
||||||
self.action = retryAction
|
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 {
|
fileprivate extension Client.Error {
|
||||||
var systemImageName: String {
|
var systemImageName: String {
|
||||||
switch self {
|
switch type {
|
||||||
case .networkError(_):
|
case .networkError(_):
|
||||||
return "wifi.exclamationmark"
|
return "wifi.exclamationmark"
|
||||||
default:
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ class ToastView: UIView {
|
||||||
|
|
||||||
private var shrinkAnimator: UIViewPropertyAnimator?
|
private var shrinkAnimator: UIViewPropertyAnimator?
|
||||||
private var recognizedGesture = false
|
private var recognizedGesture = false
|
||||||
|
private var handledLongPress = false
|
||||||
private var shouldDismissOnScroll = false
|
private var shouldDismissOnScroll = false
|
||||||
private(set) var shouldDismissAutomatically = true
|
private(set) var shouldDismissAutomatically = true
|
||||||
|
|
||||||
|
@ -102,6 +103,8 @@ class ToastView: UIView {
|
||||||
|
|
||||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(panRecognized))
|
let pan = UIPanGestureRecognizer(target: self, action: #selector(panRecognized))
|
||||||
addGestureRecognizer(pan)
|
addGestureRecognizer(pan)
|
||||||
|
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(longPressRecognized))
|
||||||
|
addGestureRecognizer(longPress)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func layoutSubviews() {
|
override func layoutSubviews() {
|
||||||
|
@ -154,6 +157,8 @@ class ToastView: UIView {
|
||||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
super.touchesEnded(touches, with: event)
|
super.touchesEnded(touches, with: event)
|
||||||
|
|
||||||
|
handledLongPress = false
|
||||||
|
|
||||||
if !recognizedGesture {
|
if !recognizedGesture {
|
||||||
guard let shrinkAnimator = shrinkAnimator else {
|
guard let shrinkAnimator = shrinkAnimator else {
|
||||||
return
|
return
|
||||||
|
@ -252,4 +257,12 @@ class ToastView: UIView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func longPressRecognized() {
|
||||||
|
guard !handledLongPress else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configuration.longPressAction?(self)
|
||||||
|
handledLongPress = true
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue