feat: implement pick server feature

This commit is contained in:
jk234ert 2021-02-25 14:09:19 +08:00
parent 027fec1cc9
commit 50035c1359
7 changed files with 453 additions and 43 deletions

View File

@ -7,6 +7,8 @@
import UIKit
import Combine
import OSLog
import MastodonSDK
final class PickServerViewController: UIViewController, NeedsDependency {
@ -17,17 +19,6 @@ final class PickServerViewController: UIViewController, NeedsDependency {
var viewModel: PickServerViewModel!
let titleLabel: UILabel = {
let label = UILabel()
label.font = .boldSystemFont(ofSize: 34)
label.textColor = Asset.Colors.Label.primary.color
label.text = L10n.Scene.ServerPicker.title
label.adjustsFontForContentSizeCategory = true
label.translatesAutoresizingMaskIntoConstraints = false
label.numberOfLines = 0
return label
}()
let tableView: UITableView = {
let tableView = ControlContainableTableView()
tableView.register(PickServerTitleCell.self, forCellReuseIdentifier: String(describing: PickServerTitleCell.self))
@ -37,7 +28,7 @@ final class PickServerViewController: UIViewController, NeedsDependency {
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .none
tableView.backgroundColor = .clear
tableView.keyboardDismissMode = .onDrag
tableView.translatesAutoresizingMaskIntoConstraints = false
return tableView
@ -83,21 +74,185 @@ extension PickServerViewController {
case .SignUp:
nextStepButton.setTitle(L10n.Common.Controls.Actions.continue, for: .normal)
}
nextStepButton.addTarget(self, action: #selector(nextStepButtonDidClicked(_:)), for: .touchUpInside)
viewModel.tableView = tableView
tableView.delegate = viewModel
tableView.dataSource = viewModel
viewModel.searchedServers
viewModel
.searchedServers
.receive(on: DispatchQueue.main)
.sink { completion in
print("22")
.sink { _ in
} receiveValue: { [weak self] servers in
self?.tableView.reloadSections(IndexSet(integer: 3), with: .automatic)
if let selectedServer = self?.viewModel.selectedServer.value, servers.contains(selectedServer) {
// Previously selected server is still in the list, do nothing
} else {
// Previously selected server is not in the updated list, reset the selectedServer's value
self?.viewModel.selectedServer.send(nil)
}
}
.store(in: &disposeBag)
viewModel
.selectedServer
.map {
$0 != nil
}
.assign(to: \.isEnabled, on: nextStepButton)
.store(in: &disposeBag)
viewModel.error
.compactMap { $0 }
.receive(on: DispatchQueue.main)
.sink { [weak self] error in
guard let self = self else { return }
let alertController = UIAlertController(error, preferredStyle: .alert)
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
alertController.addAction(okAction)
self.coordinator.present(
scene: .alertController(alertController: alertController),
from: nil,
transition: .alertController(animated: true, completion: nil)
)
}
.store(in: &disposeBag)
viewModel
.authenticated
.receive(on: DispatchQueue.main)
.flatMap { [weak self] (domain, user) -> AnyPublisher<Result<Bool, Error>, Never> in
guard let self = self else { return Just(.success(false)).eraseToAnyPublisher() }
return self.context.authenticationService.activeMastodonUser(domain: domain, userID: user.id)
}
.sink { [weak self] result in
guard let self = self else { return }
switch result {
case .failure(let error):
assertionFailure(error.localizedDescription)
case .success(let isActived):
assert(isActived)
self.coordinator.setup()
}
}
.store(in: &disposeBag)
viewModel.fetchAllServers()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(false, animated: animated)
}
@objc
private func nextStepButtonDidClicked(_ sender: UIButton) {
switch viewModel.mode {
case .SignIn:
doSignIn()
case .SignUp:
doSignUp()
}
}
private func doSignIn() {
guard let server = viewModel.selectedServer.value else { return }
context.apiService.createApplication(domain: server.domain)
.tryMap { response -> PickServerViewModel.AuthenticateInfo in
let application = response.value
guard let info = PickServerViewModel.AuthenticateInfo(domain: server.domain, application: application) else {
throw APIService.APIError.explicit(.badResponse)
}
return info
}
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
guard let self = self else { return }
// self.viewModel.isAuthenticating.value = false
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign in fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
self.viewModel.error.send(error)
case .finished:
break
}
} receiveValue: { [weak self] info in
guard let self = self else { return }
let mastodonPinBasedAuthenticationViewModel = MastodonPinBasedAuthenticationViewModel(authenticateURL: info.authorizeURL)
self.viewModel.authenticate(
info: info,
pinCodePublisher: mastodonPinBasedAuthenticationViewModel.pinCodePublisher
)
self.viewModel.mastodonPinBasedAuthenticationViewController = self.coordinator.present(
scene: .mastodonPinBasedAuthentication(viewModel: mastodonPinBasedAuthenticationViewModel),
from: nil,
transition: .modal(animated: true, completion: nil)
)
}
.store(in: &disposeBag)
}
private func doSignUp() {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
guard let server = viewModel.selectedServer.value else { return }
// viewModel.isRegistering.value = true
context.apiService.instance(domain: server.domain)
.compactMap { [weak self] response -> AnyPublisher<PickServerViewModel.SignUpResponseFirst, Error>? in
guard let self = self else { return nil }
guard response.value.registrations != false else {
return Fail(error: AuthenticationViewModel.AuthenticationError.registrationClosed).eraseToAnyPublisher()
}
return self.context.apiService.createApplication(domain: server.domain)
.map { PickServerViewModel.SignUpResponseFirst(instance: response, application: $0) }
.eraseToAnyPublisher()
}
.switchToLatest()
.tryMap { response -> PickServerViewModel.SignUpResponseSecond in
let application = response.application.value
guard let authenticateInfo = AuthenticationViewModel.AuthenticateInfo(domain: server.domain, application: application) else {
throw APIService.APIError.explicit(.badResponse)
}
return PickServerViewModel.SignUpResponseSecond(instance: response.instance, authenticateInfo: authenticateInfo)
}
.compactMap { [weak self] response -> AnyPublisher<PickServerViewModel.SignUpResponseThird, Error>? in
guard let self = self else { return nil }
let instance = response.instance
let authenticateInfo = response.authenticateInfo
return self.context.apiService.applicationAccessToken(
domain: server.domain,
clientID: authenticateInfo.clientID,
clientSecret: authenticateInfo.clientSecret
)
.map { PickServerViewModel.SignUpResponseThird(instance: instance, authenticateInfo: authenticateInfo, applicationToken: $0) }
.eraseToAnyPublisher()
}
.switchToLatest()
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
guard let self = self else { return }
// self.viewModel.isRegistering.value = false
switch completion {
case .failure(let error):
self.viewModel.error.send(error)
case .finished:
break
}
} receiveValue: { [weak self] response in
guard let self = self else { return }
let mastodonRegisterViewModel = MastodonRegisterViewModel(
domain: server.domain,
authenticateInfo: response.authenticateInfo,
instance: response.instance.value,
applicationToken: response.applicationToken.value
)
self.coordinator.present(scene: .mastodonRegister(viewModel: mastodonRegisterViewModel), from: self, transition: .show)
}
.store(in: &disposeBag)
}
}

View File

@ -6,8 +6,10 @@
//
import UIKit
import OSLog
import Combine
import MastodonSDK
import CoreDataStack
class PickServerViewModel: NSObject {
enum PickServerMode {
@ -77,12 +79,18 @@ class PickServerViewModel: NSObject {
let allServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
let searchedServers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([])
let nextButtonEnable = CurrentValueSubject<Bool, Never>(false)
let selectedServer = CurrentValueSubject<Mastodon.Entity.Server?, Never>(nil)
let error = PassthroughSubject<Error, Never>()
let authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>()
private var disposeBag = Set<AnyCancellable>()
weak var tableView: UITableView?
private var expandServerDomainSet = Set<String>()
var mastodonPinBasedAuthenticationViewController: UIViewController?
init(context: AppContext, mode: PickServerMode) {
self.context = context
self.mode = mode
@ -101,26 +109,37 @@ class PickServerViewModel: NSObject {
searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(),
allServers
)
.flatMap { [weak self] (selectCategoryIndex, searchText, allServers) -> AnyPublisher<[Mastodon.Entity.Server], Error> in
guard let self = self else { return Just([]).setFailureType(to: Error.self).eraseToAnyPublisher() }
.flatMap { [weak self] (selectCategoryIndex, searchText, allServers) -> AnyPublisher<Result<[Mastodon.Entity.Server], Error>, Never> in
guard let self = self else { return Just(Result.success([])).eraseToAnyPublisher() }
// 1. Search from the servers recorded in joinmastodon.org
let searchedServersFromAPI = self.searchServersFromAPI(category: self.categories[selectCategoryIndex], searchText: searchText, allServers: allServers)
if !searchedServersFromAPI.isEmpty {
// If found servers, just return
return Just(searchedServersFromAPI).setFailureType(to: Error.self).eraseToAnyPublisher()
return Just(Result.success(searchedServersFromAPI)).eraseToAnyPublisher()
}
// 2. No server found in the recorded list, check if searchText is a valid mastodon server domain
if let toSearchText = searchText, !toSearchText.isEmpty {
return self.context.apiService.instance(domain: toSearchText)
.map { return [Mastodon.Entity.Server(instance: $0.value)] }.eraseToAnyPublisher()
.map { return Result.success([Mastodon.Entity.Server(instance: $0.value)]) }
.catch({ error -> Just<Result<[Mastodon.Entity.Server], Error>> in
return Just(Result.failure(error))
})
.eraseToAnyPublisher()
}
return Just(searchedServersFromAPI).setFailureType(to: Error.self).eraseToAnyPublisher()
return Just(Result.success(searchedServersFromAPI)).eraseToAnyPublisher()
}
.sink { completion in
print("1")
} receiveValue: { [weak self] servers in
self?.searchedServers.send(servers)
.sink { _ in
} receiveValue: { [weak self] result in
switch result {
case .success(let servers):
self?.searchedServers.send(servers)
case .failure(let error):
// TODO: What should be presented when user inputs invalid search text?
self?.searchedServers.send([])
}
}
.store(in: &disposeBag)
@ -129,7 +148,6 @@ class PickServerViewModel: NSObject {
func fetchAllServers() {
context.apiService.servers(language: nil, category: nil)
.receive(on: DispatchQueue.main)
.sink { error in
print("11")
} receiveValue: { [weak self] result in
@ -152,8 +170,8 @@ class PickServerViewModel: NSObject {
}
// 2. Filter the searchText
.filter {
if let searchText = searchText {
return $0.domain.contains(searchText)
if let searchText = searchText, !searchText.isEmpty {
return $0.domain.lowercased().contains(searchText.lowercased())
} else {
return true
}
@ -181,6 +199,24 @@ extension PickServerViewModel: UITableViewDelegate {
}
}
func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
if tableView.indexPathForSelectedRow == indexPath {
tableView.deselectRow(at: indexPath, animated: false)
selectedServer.send(nil)
return nil
}
return indexPath
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
selectedServer.send(searchedServers.value[indexPath.row])
}
func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: false)
selectedServer.send(nil)
}
}
extension PickServerViewModel: UITableViewDataSource {
@ -222,7 +258,19 @@ extension PickServerViewModel: UITableViewDataSource {
return cell
case .serverList:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCell.self), for: indexPath) as! PickServerCell
cell.server = searchedServers.value[indexPath.row]
let server = searchedServers.value[indexPath.row]
cell.server = server
if expandServerDomainSet.contains(server.domain) {
cell.mode = .expand
} else {
cell.mode = .collapse
}
if server == selectedServer.value {
tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
} else {
tableView.deselectRow(at: indexPath, animated: false)
}
cell.delegate = self
return cell
}
@ -254,9 +302,181 @@ extension PickServerViewModel: PickServerSearchCellDelegate {
}
extension PickServerViewModel: PickServerCellDelegate {
func pickServerCell(modeChange updates: (() -> Void)) {
tableView?.beginUpdates()
func pickServerCell(modeChange server: Mastodon.Entity.Server, newMode: PickServerCell.Mode, updates: (() -> Void)) {
if newMode == .collapse {
expandServerDomainSet.remove(server.domain)
} else {
expandServerDomainSet.insert(server.domain)
}
tableView?.performBatchUpdates(updates, completion: nil)
tableView?.endUpdates()
}
}
// MARK: - SignIn methods & structs
extension PickServerViewModel {
enum AuthenticationError: Error, LocalizedError {
case badCredentials
case registrationClosed
var errorDescription: String? {
switch self {
case .badCredentials: return "Bad Credentials"
case .registrationClosed: return "Registration Closed"
}
}
var failureReason: String? {
switch self {
case .badCredentials: return "Credentials invalid."
case .registrationClosed: return "Server disallow registration."
}
}
var helpAnchor: String? {
switch self {
case .badCredentials: return "Please try again."
case .registrationClosed: return "Please try another domain."
}
}
}
struct AuthenticateInfo {
let domain: String
let clientID: String
let clientSecret: String
let authorizeURL: URL
init?(domain: String, application: Mastodon.Entity.Application) {
self.domain = domain
guard let clientID = application.clientID,
let clientSecret = application.clientSecret else { return nil }
self.clientID = clientID
self.clientSecret = clientSecret
self.authorizeURL = {
let query = Mastodon.API.OAuth.AuthorizeQuery(clientID: clientID)
let url = Mastodon.API.OAuth.authorizeURL(domain: domain, query: query)
return url
}()
}
}
func authenticate(info: AuthenticateInfo, pinCodePublisher: PassthroughSubject<String, Never>) {
pinCodePublisher
.handleEvents(receiveOutput: { [weak self] _ in
guard let self = self else { return }
// self.isAuthenticating.value = true
self.mastodonPinBasedAuthenticationViewController?.dismiss(animated: true, completion: nil)
self.mastodonPinBasedAuthenticationViewController = nil
})
.compactMap { [weak self] code -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error>? in
guard let self = self else { return nil }
return self.context.apiService
.userAccessToken(
domain: info.domain,
clientID: info.clientID,
clientSecret: info.clientSecret,
code: code
)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
let token = response.value
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign in success. Token: %s", ((#file as NSString).lastPathComponent), #line, #function, token.accessToken)
return Self.verifyAndSaveAuthentication(
context: self.context,
info: info,
userToken: token
)
}
.eraseToAnyPublisher()
}
.switchToLatest()
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
guard let self = self else { return }
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: swap user access token swap fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
// self.isAuthenticating.value = false
self.error.send(error)
case .finished:
break
}
} receiveValue: { [weak self] response in
guard let self = self else { return }
let account = response.value
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: user %s sign in success", ((#file as NSString).lastPathComponent), #line, #function, account.username)
self.authenticated.send((domain: info.domain, account: account))
}
.store(in: &self.disposeBag)
}
static func verifyAndSaveAuthentication(
context: AppContext,
info: AuthenticateInfo,
userToken: Mastodon.Entity.Token
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> {
let authorization = Mastodon.API.OAuth.Authorization(accessToken: userToken.accessToken)
let managedObjectContext = context.backgroundManagedObjectContext
return context.apiService.accountVerifyCredentials(
domain: info.domain,
authorization: authorization
)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
let account = response.value
let mastodonUserRequest = MastodonUser.sortedFetchRequest
mastodonUserRequest.predicate = MastodonUser.predicate(domain: info.domain, id: account.id)
mastodonUserRequest.fetchLimit = 1
guard let mastodonUser = try? managedObjectContext.fetch(mastodonUserRequest).first else {
return Fail(error: AuthenticationError.badCredentials).eraseToAnyPublisher()
}
let property = MastodonAuthentication.Property(
domain: info.domain,
userID: mastodonUser.id,
username: mastodonUser.username,
appAccessToken: userToken.accessToken, // TODO: swap app token
userAccessToken: userToken.accessToken,
clientID: info.clientID,
clientSecret: info.clientSecret
)
return managedObjectContext.performChanges {
_ = APIService.CoreData.createOrMergeMastodonAuthentication(
into: managedObjectContext,
for: mastodonUser,
in: info.domain,
property: property,
networkDate: response.networkDate
)
}
.tryMap { result in
switch result {
case .failure(let error): throw error
case .success: return response
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}
// MARK: - SignUp methods & structs
extension PickServerViewModel {
struct SignUpResponseFirst {
let instance: Mastodon.Response.Content<Mastodon.Entity.Instance>
let application: Mastodon.Response.Content<Mastodon.Entity.Application>
}
struct SignUpResponseSecond {
let instance: Mastodon.Response.Content<Mastodon.Entity.Instance>
let authenticateInfo: AuthenticationViewModel.AuthenticateInfo
}
struct SignUpResponseThird {
let instance: Mastodon.Response.Content<Mastodon.Entity.Instance>
let authenticateInfo: AuthenticationViewModel.AuthenticateInfo
let applicationToken: Mastodon.Response.Content<Mastodon.Entity.Token>
}
}

View File

@ -10,7 +10,7 @@ import MastodonSDK
import Kingfisher
protocol PickServerCellDelegate: class {
func pickServerCell(modeChange updates: (() -> Void))
func pickServerCell(modeChange server: Mastodon.Entity.Server, newMode: PickServerCell.Mode, updates: (() -> Void))
}
class PickServerCell: UITableViewCell {
@ -40,6 +40,8 @@ class PickServerCell: UITableViewCell {
private var checkbox: UIImageView = {
let imageView = UIImageView()
imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body)
imageView.tintColor = Asset.Colors.lightSecondaryText.color
imageView.contentMode = .scaleAspectFill
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
@ -80,7 +82,7 @@ class PickServerCell: UITableViewCell {
}()
private var expandButton: UIButton = {
let button = UIButton(type: .system)
let button = UIButton(type: .custom)
button.setTitle(L10n.Scene.ServerPicker.Button.seeMore, for: .normal)
button.setTitle(L10n.Scene.ServerPicker.Button.seeLess, for: .selected)
button.setTitleColor(Asset.Colors.lightBrandBlue.color, for: .normal)
@ -238,6 +240,7 @@ extension PickServerCell {
checkbox.heightAnchor.constraint(equalToConstant: 22),
bgView.trailingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 16),
checkbox.leadingAnchor.constraint(equalTo: domainLabel.trailingAnchor, constant: 16),
checkbox.centerYAnchor.constraint(equalTo: domainLabel.centerYAnchor),
descriptionLabel.leadingAnchor.constraint(equalTo: bgView.leadingAnchor, constant: 16),
descriptionLabel.topAnchor.constraint(equalTo: domainLabel.firstBaselineAnchor, constant: 8),
@ -284,20 +287,31 @@ extension PickServerCell {
switch mode {
case .collapse:
expandBox.isHidden = true
expandButton.isSelected = false
NSLayoutConstraint.deactivate(expandConstraints)
NSLayoutConstraint.activate(collapseConstraints)
case .expand:
expandBox.isHidden = false
expandButton.isSelected = true
NSLayoutConstraint.activate(expandConstraints)
NSLayoutConstraint.deactivate(collapseConstraints)
}
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
if selected {
checkbox.image = UIImage(systemName: "checkmark.circle.fill")
} else {
checkbox.image = UIImage(systemName: "circle")
}
}
@objc
private func expandButtonDidClicked(_ sender: UIButton) {
delegate?.pickServerCell(modeChange: {
let newMode: Mode = mode == .collapse ? .expand : .collapse
self.mode = newMode
let newMode: Mode = mode == .collapse ? .expand : .collapse
delegate?.pickServerCell(modeChange: server!, newMode: newMode, updates: { [weak self] in
self?.mode = newMode
})
}
}
@ -316,7 +330,17 @@ extension PickServerCell {
.transition(.fade(1))
])
langValueLabel.text = serverInfo.language.uppercased()
usersValueLabel.text = "\(serverInfo.totalUsers)"
usersValueLabel.text = parseUsersCount(serverInfo.totalUsers)
categoryValueLabel.text = serverInfo.category.uppercased()
}
private func parseUsersCount(_ usersCount: Int) -> String {
switch usersCount {
case 0..<1000:
return "\(usersCount)"
default:
let usersCountInThousand = Float(usersCount) / 1000.0
return String(format: "%.1fK", usersCountInThousand)
}
}
}

View File

@ -50,6 +50,8 @@ class PickServerSearchCell: UITableViewCell {
attributes: [.font: UIFont.preferredFont(forTextStyle: .headline),
.foregroundColor: Asset.Colors.lightSecondaryText.color.withAlphaComponent(0.6)])
textField.clearButtonMode = .whileEditing
textField.autocapitalizationType = .none
textField.autocorrectionType = .no
return textField
}()

View File

@ -23,7 +23,8 @@ extension PrimaryActionButton {
private func _init() {
titleLabel?.font = .preferredFont(forTextStyle: .headline)
setTitleColor(Asset.Colors.lightWhite.color, for: .normal)
backgroundColor = Asset.Colors.lightBrandBlue.color
setBackgroundImage(UIImage.placeholder(color: Asset.Colors.lightBrandBlue.color), for: .normal)
setBackgroundImage(UIImage.placeholder(color: Asset.Colors.lightDisabled.color), for: .disabled)
applyCornerRadius(radius: 10)
setInsets(forContentPadding: UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0), imageTitlePadding: 0)
}

View File

@ -60,6 +60,10 @@ extension WelcomeViewController {
overrideUserInterfaceStyle = .light
view.backgroundColor = Asset.Colors.Background.onboardingBackground.color
navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default)
navigationController?.navigationBar.shadowImage = UIImage()
navigationController?.navigationBar.isTranslucent = true
navigationController?.view.backgroundColor = .clear
view.addSubview(logoImageView)
NSLayoutConstraint.activate([

View File

@ -9,7 +9,7 @@ import Foundation
extension Mastodon.Entity {
public struct Server: Codable {
public struct Server: Codable, Equatable {
public let domain: String
public let version: String
public let description: String
@ -40,8 +40,8 @@ extension Mastodon.Entity {
public init(instance: Instance) {
self.domain = instance.title
self.version = "\(instance.version)"
self.description = instance.description
self.version = instance.version ?? ""
self.description = instance.shortDescription ?? instance.description
self.language = instance.languages?.first ?? ""
self.languages = instance.languages ?? []
self.region = "Unknown" // TODO: how to handle properties not in an instance
@ -52,6 +52,10 @@ extension Mastodon.Entity {
self.lastWeekUsers = 0
self.approvalRequired = instance.approvalRequired ?? false
}
public static func == (lhs: Self, rhs: Self) -> Bool {
return lhs.domain.caseInsensitiveCompare(rhs.domain) == .orderedSame
}
}
}