Merge pull request #551 from mastodon/540-better-onboarding

Better Login
This commit is contained in:
Nathan Mattes 2022-11-16 09:44:27 +01:00 committed by GitHub
commit e208aedb7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 653 additions and 607 deletions

View File

@ -74,8 +74,8 @@
"take_photo": "Take Photo",
"save_photo": "Save Photo",
"copy_photo": "Copy Photo",
"sign_in": "Sign In",
"sign_up": "Sign Up",
"sign_in": "Log in",
"sign_up": "Create account",
"see_more": "See More",
"preview": "Preview",
"share": "Share",
@ -218,10 +218,16 @@
"get_started": "Get Started",
"log_in": "Log In"
},
"login": {
"title": "Welcome back",
"subtitle": "Log you in on the server you created your account on.",
"server_search_field": {
"placeholder": "Enter URL or search for your server"
}
}
"server_picker": {
"title": "Mastodon is made of users in different servers.",
"subtitle": "Pick a server based on your interests, region, or a general purpose one.",
"subtitle_extend": "Pick a server based on your interests, region, or a general purpose one. Each server is operated by an entirely independent organization or individual.",
"subtitle": "Pick a server based on your region, interests, or a general purpose one. You can still chat with anyone on Mastodon, regardless of your servers.",
"button": {
"category": {
"all": "All",
@ -248,8 +254,7 @@
"category": "CATEGORY"
},
"input": {
"placeholder": "Search servers",
"search_servers_or_enter_url": "Search servers or enter URL"
"search_servers_or_enter_url": "Search communities or enter URL"
},
"empty_state": {
"finding_servers": "Finding available servers...",
@ -719,4 +724,4 @@
"title": "Bookmarks"
}
}
}
}

View File

@ -74,8 +74,8 @@
"take_photo": "Take Photo",
"save_photo": "Save Photo",
"copy_photo": "Copy Photo",
"sign_in": "Sign In",
"sign_up": "Sign Up",
"sign_in": "Log in",
"sign_up": "Create account",
"see_more": "See More",
"preview": "Preview",
"share": "Share",
@ -218,10 +218,16 @@
"get_started": "Get Started",
"log_in": "Log In"
},
"login": {
"title": "Welcome back",
"subtitle": "Log you in on the server you created your account on.",
"server_search_field": {
"placeholder": "Enter URL or search for your server"
}
},
"server_picker": {
"title": "Mastodon is made of users in different servers.",
"subtitle": "Pick a server based on your interests, region, or a general purpose one.",
"subtitle_extend": "Pick a server based on your interests, region, or a general purpose one. Each server is operated by an entirely independent organization or individual.",
"subtitle": "Pick a server based on your region, interests, or a general purpose one. You can still chat with anyone on Mastodon, regardless of your servers.",
"button": {
"category": {
"all": "All",
@ -248,8 +254,7 @@
"category": "CATEGORY"
},
"input": {
"placeholder": "Search servers",
"search_servers_or_enter_url": "Search servers or enter URL"
"search_servers_or_enter_url": "Search communities or enter URL"
},
"empty_state": {
"finding_servers": "Finding available servers...",
@ -719,4 +724,4 @@
"title": "Bookmarks"
}
}
}
}

View File

@ -88,6 +88,11 @@
62FD27D52893708A00B205C5 /* BookmarkViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FD27D42893708A00B205C5 /* BookmarkViewModel+Diffable.swift */; };
87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; };
C24C97032922F30500BAE8CB /* RefreshControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = C24C97022922F30500BAE8CB /* RefreshControl.swift */; };
D87BFC8B291D5C6B00FEE264 /* MastodonLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87BFC8A291D5C6B00FEE264 /* MastodonLoginView.swift */; };
D87BFC8D291EB81200FEE264 /* MastodonLoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87BFC8C291EB81200FEE264 /* MastodonLoginViewModel.swift */; };
D87BFC8F291EC26A00FEE264 /* MastodonLoginServerTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87BFC8E291EC26A00FEE264 /* MastodonLoginServerTableViewCell.swift */; };
D8916DC029211BE500124085 /* ContentSizedTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8916DBF29211BE500124085 /* ContentSizedTableView.swift */; };
D8A6AB6C291C5136003AB663 /* MastodonLoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A6AB6B291C5136003AB663 /* MastodonLoginViewController.swift */; };
DB0009A626AEE5DC009B9D2D /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = DB0009A926AEE5DC009B9D2D /* Intents.intentdefinition */; settings = {ATTRIBUTES = (codegen, ); }; };
DB0009A726AEE5DC009B9D2D /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = DB0009A926AEE5DC009B9D2D /* Intents.intentdefinition */; };
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; };
@ -608,6 +613,11 @@
C3789232A52F43529CA67E95 /* Pods-MastodonIntent.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonIntent.asdk - debug.xcconfig"; path = "Target Support Files/Pods-MastodonIntent/Pods-MastodonIntent.asdk - debug.xcconfig"; sourceTree = "<group>"; };
CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-AppShared.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-AppShared/Pods-Mastodon-AppShared.debug.xcconfig"; sourceTree = "<group>"; };
D87BFC8A291D5C6B00FEE264 /* MastodonLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonLoginView.swift; sourceTree = "<group>"; };
D87BFC8C291EB81200FEE264 /* MastodonLoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonLoginViewModel.swift; sourceTree = "<group>"; };
D87BFC8E291EC26A00FEE264 /* MastodonLoginServerTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonLoginServerTableViewCell.swift; sourceTree = "<group>"; };
D8916DBF29211BE500124085 /* ContentSizedTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentSizedTableView.swift; sourceTree = "<group>"; };
D8A6AB6B291C5136003AB663 /* MastodonLoginViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonLoginViewController.swift; sourceTree = "<group>"; };
DB0009A826AEE5DC009B9D2D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; name = Base; path = Base.lproj/Intents.intentdefinition; sourceTree = "<group>"; };
DB0009AD26AEE5E4009B9D2D /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Intents.strings; sourceTree = "<group>"; };
DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = "<group>"; };
@ -632,7 +642,6 @@
DB0618022785A7100030EE79 /* RegisterSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterSection.swift; sourceTree = "<group>"; };
DB0618042785A73D0030EE79 /* RegisterItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterItem.swift; sourceTree = "<group>"; };
DB0618062785A8880030EE79 /* MastodonRegisterViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonRegisterViewModel+Diffable.swift"; sourceTree = "<group>"; };
DB0618092785B2AB0030EE79 /* MastodonRegisterAvatarTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterAvatarTableViewCell.swift; sourceTree = "<group>"; };
DB0A322D280EE9FD001729D2 /* DiscoveryIntroBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryIntroBannerView.swift; sourceTree = "<group>"; };
DB0C947626A7FE840088FB11 /* NotificationAvatarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAvatarButton.swift; sourceTree = "<group>"; };
DB0EF72A26FDB1D200347686 /* SidebarListCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarListCollectionViewCell.swift; sourceTree = "<group>"; };
@ -855,8 +864,6 @@
DB7A9F922818F33C0016AF98 /* MastodonServerRulesViewController+Debug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonServerRulesViewController+Debug.swift"; sourceTree = "<group>"; };
DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderViewModel.swift; sourceTree = "<group>"; };
DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = "<group>"; };
DB8481142788121200BBEABA /* MastodonRegisterTextFieldTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterTextFieldTableViewCell.swift; sourceTree = "<group>"; };
DB84811627883C2600BBEABA /* MastodonRegisterPasswordHintTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterPasswordHintTableViewCell.swift; sourceTree = "<group>"; };
DB848E32282B62A800A302CC /* ReportResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportResultView.swift; sourceTree = "<group>"; };
DB852D1826FAEB6B00FC9D81 /* SidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarViewController.swift; sourceTree = "<group>"; };
DB852D1B26FB021500FC9D81 /* RootSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootSplitViewController.swift; sourceTree = "<group>"; };
@ -1497,9 +1504,22 @@
path = Bookmark;
sourceTree = "<group>";
};
D8A6AB68291C50F3003AB663 /* Login */ = {
isa = PBXGroup;
children = (
D8A6AB6B291C5136003AB663 /* MastodonLoginViewController.swift */,
D87BFC8A291D5C6B00FEE264 /* MastodonLoginView.swift */,
D87BFC8C291EB81200FEE264 /* MastodonLoginViewModel.swift */,
D87BFC8E291EC26A00FEE264 /* MastodonLoginServerTableViewCell.swift */,
D8916DBF29211BE500124085 /* ContentSizedTableView.swift */,
);
path = Login;
sourceTree = "<group>";
};
DB01409B25C40BB600F9F3CF /* Onboarding */ = {
isa = PBXGroup;
children = (
D8A6AB68291C50F3003AB663 /* Login */,
DB68A03825E900CC00CFDF14 /* Share */,
0FAA0FDD25E0B5700017CCDE /* Welcome */,
0FAA102525E1125D0017CCDE /* PickServer */,
@ -1564,16 +1584,6 @@
path = Cell;
sourceTree = "<group>";
};
DB06180B2785B2AF0030EE79 /* Cell */ = {
isa = PBXGroup;
children = (
DB0618092785B2AB0030EE79 /* MastodonRegisterAvatarTableViewCell.swift */,
DB8481142788121200BBEABA /* MastodonRegisterTextFieldTableViewCell.swift */,
DB84811627883C2600BBEABA /* MastodonRegisterPasswordHintTableViewCell.swift */,
);
path = Cell;
sourceTree = "<group>";
};
DB0A322F280EEA00001729D2 /* View */ = {
isa = PBXGroup;
children = (
@ -2540,7 +2550,6 @@
DBE0821A25CD382900FD6BBD /* Register */ = {
isa = PBXGroup;
children = (
DB06180B2785B2AF0030EE79 /* Cell */,
DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */,
2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */,
DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */,
@ -3185,6 +3194,7 @@
DB5B54A32833BD1A00DEF8B2 /* UserListViewModel.swift in Sources */,
DBF156DF2701B17600EC00B7 /* SidebarAddAccountCollectionViewCell.swift in Sources */,
DB0617F1278413D00030EE79 /* PickServerServerSectionTableHeaderView.swift in Sources */,
D87BFC8F291EC26A00FEE264 /* MastodonLoginServerTableViewCell.swift in Sources */,
DB0FCB7E27958957006C02E2 /* StatusThreadRootTableViewCell+ViewModel.swift in Sources */,
DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */,
DB938F0926240F3C00E5B6C1 /* RemoteThreadViewModel.swift in Sources */,
@ -3304,9 +3314,11 @@
DB63F769279A5EBB00455B82 /* NotificationTimelineViewModel+Diffable.swift in Sources */,
DBFEEC9B279BDDD9004F81DD /* ProfileAboutViewModel+Diffable.swift in Sources */,
DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */,
D8916DC029211BE500124085 /* ContentSizedTableView.swift in Sources */,
DB0618012785732C0030EE79 /* ServerRulesTableViewCell.swift in Sources */,
DB98EB5C27B10A730082E365 /* ReportSupplementaryViewModel.swift in Sources */,
DB0617EF277F12720030EE79 /* NavigationActionView.swift in Sources */,
D87BFC8D291EB81200FEE264 /* MastodonLoginViewModel.swift in Sources */,
DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */,
2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */,
DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */,
@ -3375,6 +3387,7 @@
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */,
DB4F097526A037F500D62E92 /* SearchHistoryViewModel.swift in Sources */,
DB3EA8E9281B7A3700598866 /* DiscoveryCommunityViewModel.swift in Sources */,
D87BFC8B291D5C6B00FEE264 /* MastodonLoginView.swift in Sources */,
DB6180F826391D660018D199 /* MediaPreviewingViewController.swift in Sources */,
DBEFCD71282A12B200C0ABEA /* ReportReasonViewController.swift in Sources */,
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
@ -3436,6 +3449,7 @@
DB697DDB278F4DE3004EF2F7 /* DataSourceProvider+StatusTableViewCellDelegate.swift in Sources */,
DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */,
DBB45B5627B39FC9002DC5A7 /* MediaPreviewVideoViewController.swift in Sources */,
D8A6AB6C291C5136003AB663 /* MastodonLoginViewController.swift in Sources */,
DB0FCB8027968F70006C02E2 /* MastodonStatusThreadViewModel.swift in Sources */,
DB67D08627312E67006A36CF /* WizardViewController.swift in Sources */,
DB6746EB278ED8B0008A6B94 /* PollOptionView+Configuration.swift in Sources */,

View File

@ -149,6 +149,7 @@ extension SceneCoordinator {
case mastodonConfirmEmail(viewModel: MastodonConfirmEmailViewModel)
case mastodonResendEmail(viewModel: MastodonResendEmailViewModel)
case mastodonWebView(viewModel: WebViewModel)
case mastodonLogin
// search
case searchDetail(viewModel: SearchDetailViewModel)
@ -199,6 +200,7 @@ extension SceneCoordinator {
case .welcome,
.mastodonPickServer,
.mastodonRegister,
.mastodonLogin,
.mastodonServerRules,
.mastodonConfirmEmail,
.mastodonResendEmail:
@ -403,6 +405,13 @@ private extension SceneCoordinator {
let _viewController = MastodonConfirmEmailViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonLogin:
let loginViewController = MastodonLoginViewController(appContext: appContext,
authenticationViewModel: AuthenticationViewModel(context: appContext, coordinator: self, isAuthenticationExist: false),
sceneCoordinator: self)
loginViewController.delegate = self
viewController = loginViewController
case .mastodonResendEmail(let viewModel):
let _viewController = MastodonResendEmailViewController()
_viewController.viewModel = viewModel
@ -529,5 +538,16 @@ private extension SceneCoordinator {
needs?.context = appContext
needs?.coordinator = self
}
}
//MARK: - MastodonLoginViewControllerDelegate
extension SceneCoordinator: MastodonLoginViewControllerDelegate {
func backButtonPressed(_ viewController: MastodonLoginViewController) {
viewController.navigationController?.popViewController(animated: true)
}
func nextButtonPressed(_ viewController: MastodonLoginViewController) {
viewController.login()
}
}

View File

@ -21,7 +21,6 @@ extension CategoryPickerSection {
UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak dependency] collectionView, indexPath, item -> UICollectionViewCell? in
guard let _ = dependency else { return nil }
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell
cell.categoryView.emojiLabel.text = item.emoji
cell.categoryView.titleLabel.text = item.title
cell.observe(\.isSelected, options: [.initial, .new]) { cell, _ in
cell.categoryView.highlightedIndicatorView.alpha = cell.isSelected ? 1 : 0

View File

@ -18,16 +18,14 @@ enum PickServerSection: Equatable, Hashable {
extension PickServerSection {
static func tableViewDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency,
pickServerCellDelegate: PickServerCellDelegate
dependency: NeedsDependency
) -> UITableViewDiffableDataSource<PickServerSection, PickServerItem> {
tableView.register(OnboardingHeadlineTableViewCell.self, forCellReuseIdentifier: String(describing: OnboardingHeadlineTableViewCell.self))
tableView.register(PickServerCell.self, forCellReuseIdentifier: String(describing: PickServerCell.self))
tableView.register(PickServerLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: PickServerLoaderTableViewCell.self))
return UITableViewDiffableDataSource(tableView: tableView) { [
weak dependency,
weak pickServerCellDelegate
weak dependency
] tableView, indexPath, item -> UITableViewCell? in
guard let _ = dependency else { return nil }
switch item {
@ -37,7 +35,6 @@ extension PickServerSection {
case .server(let server, let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCell.self), for: indexPath) as! PickServerCell
PickServerSection.configure(cell: cell, server: server, attribute: attribute)
cell.delegate = pickServerCellDelegate
return cell
case .loader(let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerLoaderTableViewCell.self), for: indexPath) as! PickServerLoaderTableViewCell

View File

@ -0,0 +1,22 @@
//
// MastodonLoginTableView.swift
// Mastodon
//
// Created by Nathan Mattes on 13.11.22.
//
import UIKit
// Source: https://stackoverflow.com/a/48623673
final class ContentSizedTableView: UITableView {
override var contentSize:CGSize {
didSet {
invalidateIntrinsicContentSize()
}
}
override var intrinsicContentSize: CGSize {
layoutIfNeeded()
return CGSize(width: UIView.noIntrinsicMetric, height: contentSize.height)
}
}

View File

@ -0,0 +1,12 @@
//
// MastodonLoginServerTableViewCell.swift
// Mastodon
//
// Created by Nathan Mattes on 11.11.22.
//
import UIKit
class MastodonLoginServerTableViewCell: UITableViewCell {
static let reuseIdentifier = "MastodonLoginServerTableViewCell"
}

View File

@ -0,0 +1,151 @@
//
// MastodonLoginView.swift
// Mastodon
//
// Created by Nathan Mattes on 10.11.22.
//
import UIKit
import MastodonAsset
import MastodonLocalization
class MastodonLoginView: UIView {
// List with (filtered) domains
let titleLabel: UILabel
let subtitleLabel: UILabel
private let headerStackView: UIStackView
let searchTextField: UITextField
private let searchTextFieldLeftView: UIView
private let searchTextFieldMagnifyingGlass: UIImageView
private let searchContainerLeftPaddingView: UIView
let tableView: UITableView
let navigationActionView: NavigationActionView
var bottomConstraint: NSLayoutConstraint?
override init(frame: CGRect) {
titleLabel = UILabel()
titleLabel.font = MastodonLoginViewController.largeTitleFont
titleLabel.textColor = MastodonLoginViewController.largeTitleTextColor
titleLabel.text = L10n.Scene.Login.title
titleLabel.numberOfLines = 0
subtitleLabel = UILabel()
subtitleLabel.font = MastodonLoginViewController.subTitleFont
subtitleLabel.textColor = MastodonLoginViewController.subTitleTextColor
subtitleLabel.text = L10n.Scene.Login.subtitle
subtitleLabel.numberOfLines = 0
headerStackView = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel])
headerStackView.axis = .vertical
headerStackView.spacing = 16
headerStackView.translatesAutoresizingMaskIntoConstraints = false
searchTextFieldMagnifyingGlass = UIImageView(image: UIImage(
systemName: "magnifyingglass",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .regular)
))
searchTextFieldMagnifyingGlass.tintColor = Asset.Colors.Label.secondary.color.withAlphaComponent(0.6)
searchTextFieldMagnifyingGlass.translatesAutoresizingMaskIntoConstraints = false
searchContainerLeftPaddingView = UIView()
searchContainerLeftPaddingView.translatesAutoresizingMaskIntoConstraints = false
searchTextFieldLeftView = UIView()
searchTextFieldLeftView.addSubview(searchTextFieldMagnifyingGlass)
searchTextFieldLeftView.addSubview(searchContainerLeftPaddingView)
searchTextField = UITextField()
searchTextField.translatesAutoresizingMaskIntoConstraints = false
searchTextField.backgroundColor = Asset.Scene.Onboarding.searchBarBackground.color
searchTextField.placeholder = L10n.Scene.Login.ServerSearchField.placeholder
searchTextField.leftView = searchTextFieldLeftView
searchTextField.leftViewMode = .always
searchTextField.layer.cornerRadius = 10
searchTextField.keyboardType = .URL
searchTextField.autocorrectionType = .no
searchTextField.autocapitalizationType = .none
tableView = ContentSizedTableView()
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.backgroundColor = Asset.Scene.Onboarding.textFieldBackground.color
tableView.keyboardDismissMode = .onDrag
tableView.layer.cornerRadius = 10
navigationActionView = NavigationActionView()
navigationActionView.translatesAutoresizingMaskIntoConstraints = false
super.init(frame: frame)
addSubview(headerStackView)
addSubview(searchTextField)
addSubview(tableView)
addSubview(navigationActionView)
backgroundColor = Asset.Scene.Onboarding.background.color
setupConstraints()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupConstraints() {
let bottomConstraint = safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: navigationActionView.bottomAnchor)
let constraints = [
headerStackView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor),
headerStackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
headerStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
searchTextField.topAnchor.constraint(equalTo: headerStackView.bottomAnchor, constant: 32),
searchTextField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
searchTextField.heightAnchor.constraint(equalToConstant: 55),
trailingAnchor.constraint(equalTo: searchTextField.trailingAnchor, constant: 16),
searchTextFieldMagnifyingGlass.topAnchor.constraint(equalTo: searchTextFieldLeftView.topAnchor),
searchTextFieldMagnifyingGlass.leadingAnchor.constraint(equalTo: searchTextFieldLeftView.leadingAnchor, constant: 8),
searchTextFieldMagnifyingGlass.bottomAnchor.constraint(equalTo: searchTextFieldLeftView.bottomAnchor),
searchContainerLeftPaddingView.topAnchor.constraint(equalTo: searchTextFieldLeftView.topAnchor),
searchContainerLeftPaddingView.leadingAnchor.constraint(equalTo: searchTextFieldMagnifyingGlass.trailingAnchor),
searchContainerLeftPaddingView.trailingAnchor.constraint(equalTo: searchTextFieldLeftView.trailingAnchor),
searchContainerLeftPaddingView.bottomAnchor.constraint(equalTo: searchTextFieldLeftView.bottomAnchor),
searchContainerLeftPaddingView.widthAnchor.constraint(equalToConstant: 4).priority(.defaultHigh),
tableView.topAnchor.constraint(equalTo: searchTextField.bottomAnchor, constant: 2),
tableView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
trailingAnchor.constraint(equalTo: tableView.trailingAnchor, constant: 16),
tableView.bottomAnchor.constraint(lessThanOrEqualTo: navigationActionView.topAnchor),
navigationActionView.leadingAnchor.constraint(equalTo: leadingAnchor),
navigationActionView.trailingAnchor.constraint(equalTo: trailingAnchor),
bottomConstraint,
]
self.bottomConstraint = bottomConstraint
NSLayoutConstraint.activate(constraints)
}
func updateCorners(numberOfResults: Int = 0) {
tableView.isHidden = (numberOfResults == 0)
tableView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
let maskedCorners: CACornerMask
if numberOfResults == 0 {
maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMinYCorner, .layerMaxXMaxYCorner]
} else {
maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
}
searchTextField.layer.maskedCorners = maskedCorners
}
}

View File

@ -0,0 +1,288 @@
//
// MastodonLoginViewController.swift
// Mastodon
//
// Created by Nathan Mattes on 09.11.22.
//
import UIKit
import MastodonSDK
import MastodonCore
import MastodonAsset
import Combine
import AuthenticationServices
protocol MastodonLoginViewControllerDelegate: AnyObject {
func backButtonPressed(_ viewController: MastodonLoginViewController)
func nextButtonPressed(_ viewController: MastodonLoginViewController)
}
enum MastodonLoginViewSection: Hashable {
case servers
}
class MastodonLoginViewController: UIViewController, NeedsDependency {
weak var delegate: MastodonLoginViewControllerDelegate?
var dataSource: UITableViewDiffableDataSource<MastodonLoginViewSection, Mastodon.Entity.Server>?
let viewModel: MastodonLoginViewModel
let authenticationViewModel: AuthenticationViewModel
var mastodonAuthenticationController: MastodonAuthenticationController?
weak var context: AppContext!
weak var coordinator: SceneCoordinator!
var disposeBag = Set<AnyCancellable>()
var contentView: MastodonLoginView {
view as! MastodonLoginView
}
init(appContext: AppContext, authenticationViewModel: AuthenticationViewModel, sceneCoordinator: SceneCoordinator) {
viewModel = MastodonLoginViewModel(appContext: appContext)
self.authenticationViewModel = authenticationViewModel
self.context = appContext
self.coordinator = sceneCoordinator
super.init(nibName: nil, bundle: nil)
viewModel.delegate = self
navigationItem.hidesBackButton = true
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func loadView() {
let loginView = MastodonLoginView()
loginView.navigationActionView.nextButton.addTarget(self, action: #selector(MastodonLoginViewController.nextButtonPressed(_:)), for: .touchUpInside)
loginView.navigationActionView.backButton.addTarget(self, action: #selector(MastodonLoginViewController.backButtonPressed(_:)), for: .touchUpInside)
loginView.searchTextField.addTarget(self, action: #selector(MastodonLoginViewController.textfieldDidChange(_:)), for: .editingChanged)
loginView.tableView.delegate = self
loginView.tableView.register(MastodonLoginServerTableViewCell.self, forCellReuseIdentifier: MastodonLoginServerTableViewCell.reuseIdentifier)
loginView.navigationActionView.nextButton.isEnabled = false
view = loginView
}
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShowNotification(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHideNotification(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
let dataSource = UITableViewDiffableDataSource<MastodonLoginViewSection, Mastodon.Entity.Server>(tableView: contentView.tableView) { [weak self] tableView, indexPath, itemIdentifier in
guard let cell = tableView.dequeueReusableCell(withIdentifier: MastodonLoginServerTableViewCell.reuseIdentifier, for: indexPath) as? MastodonLoginServerTableViewCell,
let self = self else {
fatalError("Wrong cell")
}
let server = self.viewModel.filteredServers[indexPath.row]
var configuration = cell.defaultContentConfiguration()
configuration.text = server.domain
cell.contentConfiguration = configuration
cell.accessoryType = .disclosureIndicator
cell.backgroundColor = Asset.Scene.Onboarding.textFieldBackground.color
return cell
}
contentView.tableView.dataSource = dataSource
self.dataSource = dataSource
contentView.updateCorners()
defer { setupNavigationBarBackgroundView() }
setupOnboardingAppearance()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.updateServers()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
contentView.searchTextField.becomeFirstResponder()
}
//MARK: - Actions
@objc func backButtonPressed(_ sender: Any) {
contentView.searchTextField.resignFirstResponder()
delegate?.backButtonPressed(self)
}
@objc func nextButtonPressed(_ sender: Any) {
contentView.searchTextField.resignFirstResponder()
delegate?.nextButtonPressed(self)
}
@objc func login() {
guard let server = viewModel.selectedServer else { return }
authenticationViewModel
.authenticated
.asyncMap { domain, user -> Result<Bool, Error> in
do {
let result = try await self.context.authenticationService.activeMastodonUser(domain: domain, userID: user.id)
return .success(result)
} catch {
return .failure(error)
}
}
.receive(on: DispatchQueue.main)
.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)
authenticationViewModel.isAuthenticating.send(true)
context.apiService.createApplication(domain: server.domain)
.tryMap { response -> AuthenticationViewModel.AuthenticateInfo in
let application = response.value
guard let info = AuthenticationViewModel.AuthenticateInfo(
domain: server.domain,
application: application,
redirectURI: response.value.redirectURI ?? APIService.oauthCallbackURL
) else {
throw APIService.APIError.explicit(.badResponse)
}
return info
}
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
guard let self = self else { return }
self.authenticationViewModel.isAuthenticating.send(false)
switch completion {
case .failure(let error):
let alert = UIAlertController.standardAlert(of: error)
self.present(alert, animated: true)
case .finished:
// do nothing. There's a subscriber above resulting in `coordinator.setup()`
break
}
} receiveValue: { [weak self] info in
guard let self else { return }
let authenticationController = MastodonAuthenticationController(
context: self.context,
authenticateURL: info.authorizeURL
)
self.mastodonAuthenticationController = authenticationController
authenticationController.authenticationSession?.presentationContextProvider = self
authenticationController.authenticationSession?.start()
self.authenticationViewModel.authenticate(
info: info,
pinCodePublisher: authenticationController.pinCodePublisher
)
}
.store(in: &disposeBag)
}
@objc func textfieldDidChange(_ textField: UITextField) {
viewModel.filterServers(withText: textField.text)
if let text = textField.text,
let domain = AuthenticationViewModel.parseDomain(from: text) {
viewModel.selectedServer = .init(domain: domain, instance: .init(domain: domain))
contentView.navigationActionView.nextButton.isEnabled = true
} else {
viewModel.selectedServer = nil
contentView.navigationActionView.nextButton.isEnabled = false
}
}
// MARK: - Notifications
@objc func keyboardWillShowNotification(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let keyboardFrameValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue,
let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber
else { return }
// inspired by https://stackoverflow.com/a/30245044
let keyboardFrame = keyboardFrameValue.cgRectValue
let keyboardOrigin = view.convert(keyboardFrame.origin, from: nil)
let intersectionY = CGRectGetMaxY(view.frame) - keyboardOrigin.y;
if intersectionY >= 0 {
contentView.bottomConstraint?.constant = intersectionY - view.safeAreaInsets.bottom
}
UIView.animate(withDuration: duration.doubleValue, delay: 0, options: .curveEaseInOut) {
self.view.layoutIfNeeded()
}
}
@objc func keyboardWillHideNotification(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber
else { return }
contentView.bottomConstraint?.constant = 0
UIView.animate(withDuration: duration.doubleValue, delay: 0, options: .curveEaseInOut) {
self.view.layoutIfNeeded()
}
}
}
// MARK: - OnboardingViewControllerAppearance
extension MastodonLoginViewController: OnboardingViewControllerAppearance { }
// MARK: - UITableViewDelegate
extension MastodonLoginViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let server = viewModel.filteredServers[indexPath.row]
viewModel.selectedServer = server
contentView.searchTextField.text = server.domain
viewModel.filterServers(withText: " ")
contentView.navigationActionView.nextButton.isEnabled = true
tableView.deselectRow(at: indexPath, animated: true)
}
}
// MARK: - MastodonLoginViewModelDelegate
extension MastodonLoginViewController: MastodonLoginViewModelDelegate {
func serversUpdated(_ viewModel: MastodonLoginViewModel) {
var snapshot = NSDiffableDataSourceSnapshot<MastodonLoginViewSection, Mastodon.Entity.Server>()
snapshot.appendSections([MastodonLoginViewSection.servers])
snapshot.appendItems(viewModel.filteredServers)
dataSource?.applySnapshot(snapshot, animated: false)
OperationQueue.main.addOperation {
let numberOfResults = viewModel.filteredServers.count
self.contentView.updateCorners(numberOfResults: numberOfResults)
}
}
}
// MARK: - ASWebAuthenticationPresentationContextProviding
extension MastodonLoginViewController: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
return view.window!
}
}

View File

@ -0,0 +1,57 @@
//
// MastodonLoginViewModel.swift
// Mastodon
//
// Created by Nathan Mattes on 11.11.22.
//
import Foundation
import MastodonSDK
import MastodonCore
import Combine
protocol MastodonLoginViewModelDelegate: AnyObject {
func serversUpdated(_ viewModel: MastodonLoginViewModel)
}
class MastodonLoginViewModel {
private var serverList: [Mastodon.Entity.Server] = []
var selectedServer: Mastodon.Entity.Server?
var filteredServers: [Mastodon.Entity.Server] = []
weak var appContext: AppContext?
weak var delegate: MastodonLoginViewModelDelegate?
var disposeBag = Set<AnyCancellable>()
init(appContext: AppContext) {
self.appContext = appContext
}
func updateServers() {
appContext?.apiService.servers().sink(receiveCompletion: { [weak self] completion in
switch completion {
case .finished:
guard let self = self else { return }
self.delegate?.serversUpdated(self)
case .failure(let error):
print(error)
}
}, receiveValue: { content in
let servers = content.value
self.serverList = servers
}).store(in: &disposeBag)
}
func filterServers(withText query: String?) {
guard let query else {
filteredServers = serverList
delegate?.serversUpdated(self)
return
}
filteredServers = serverList.filter { $0.domain.lowercased().contains(query) }.sorted {$0.totalUsers > $1.totalUsers }
delegate?.serversUpdated(self)
}
}

View File

@ -143,8 +143,7 @@ extension MastodonPickServerViewController {
viewModel.setupDiffableDataSource(
for: tableView,
dependency: self,
pickServerServerSectionTableHeaderViewDelegate: self,
pickServerCellDelegate: self
pickServerServerSectionTableHeaderViewDelegate: self
)
KeyboardResponderService
@ -172,7 +171,7 @@ extension MastodonPickServerViewController {
let alertController = UIAlertController(for: error, title: "Error", preferredStyle: .alert)
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
alertController.addAction(okAction)
self.coordinator.present(
_ = self.coordinator.present(
scene: .alertController(alertController: alertController),
from: nil,
transition: .alertController(animated: true, completion: nil)
@ -271,59 +270,6 @@ extension MastodonPickServerViewController {
}
@objc private func nextButtonDidPressed(_ sender: UIButton) {
switch viewModel.mode {
case .signIn: doSignIn()
case .signUp: doSignUp()
}
}
private func doSignIn() {
guard let server = viewModel.selectedServer.value else { return }
authenticationViewModel.isAuthenticating.send(true)
context.apiService.createApplication(domain: server.domain)
.tryMap { response -> AuthenticationViewModel.AuthenticateInfo in
let application = response.value
guard let info = AuthenticationViewModel.AuthenticateInfo(
domain: server.domain,
application: application,
redirectURI: response.value.redirectURI ?? APIService.oauthCallbackURL
) else {
throw APIService.APIError.explicit(.badResponse)
}
return info
}
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
guard let self = self else { return }
self.authenticationViewModel.isAuthenticating.send(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 authenticationController = MastodonAuthenticationController(
context: self.context,
authenticateURL: info.authorizeURL
)
self.mastodonAuthenticationController = authenticationController
authenticationController.authenticationSession?.presentationContextProvider = self
authenticationController.authenticationSession?.start()
self.authenticationViewModel.authenticate(
info: info,
pinCodePublisher: authenticationController.pinCodePublisher
)
}
.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 }
authenticationViewModel.isAuthenticating.send(true)
@ -394,7 +340,7 @@ extension MastodonPickServerViewController {
instance: response.instance.value,
applicationToken: response.applicationToken.value
)
self.coordinator.present(scene: .mastodonServerRules(viewModel: mastodonServerRulesViewModel), from: self, transition: .show)
_ = self.coordinator.present(scene: .mastodonServerRules(viewModel: mastodonServerRulesViewModel), from: self, transition: .show)
} else {
let mastodonRegisterViewModel = MastodonRegisterViewModel(
context: self.context,
@ -403,7 +349,7 @@ extension MastodonPickServerViewController {
instance: response.instance.value,
applicationToken: response.applicationToken.value
)
self.coordinator.present(scene: .mastodonRegister(viewModel: mastodonRegisterViewModel), from: nil, transition: .show)
_ = self.coordinator.present(scene: .mastodonRegister(viewModel: mastodonRegisterViewModel), from: nil, transition: .show)
}
}
.store(in: &disposeBag)
@ -503,17 +449,5 @@ extension MastodonPickServerViewController: PickServerServerSectionTableHeaderVi
}
}
// MARK: - PickServerCellDelegate
extension MastodonPickServerViewController: PickServerCellDelegate {
}
// MARK: - OnboardingViewControllerAppearance
extension MastodonPickServerViewController: OnboardingViewControllerAppearance { }
// MARK: - ASWebAuthenticationPresentationContextProviding
extension MastodonPickServerViewController: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
return view.window!
}
}

View File

@ -13,8 +13,7 @@ extension MastodonPickServerViewModel {
func setupDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency,
pickServerServerSectionTableHeaderViewDelegate: PickServerServerSectionTableHeaderViewDelegate,
pickServerCellDelegate: PickServerCellDelegate
pickServerServerSectionTableHeaderViewDelegate: PickServerServerSectionTableHeaderViewDelegate
) {
// set section header
serverSectionHeaderView.diffableDataSource = CategoryPickerSection.collectionViewDiffableDataSource(
@ -34,8 +33,7 @@ extension MastodonPickServerViewModel {
// set tableView
diffableDataSource = PickServerSection.tableViewDiffableDataSource(
for: tableView,
dependency: dependency,
pickServerCellDelegate: pickServerCellDelegate
dependency: dependency
)
var snapshot = NSDiffableDataSourceSnapshot<PickServerSection, PickServerItem>()

View File

@ -17,12 +17,7 @@ import MastodonCore
import MastodonUI
class MastodonPickServerViewModel: NSObject {
enum PickServerMode {
case signUp
case signIn
}
enum EmptyStateViewState {
case none
case loading
@ -34,7 +29,6 @@ class MastodonPickServerViewModel: NSObject {
let serverSectionHeaderView = PickServerServerSectionTableHeaderView()
// input
let mode: PickServerMode
let context: AppContext
var categoryPickerItems: [CategoryPickerItem] = {
var items: [CategoryPickerItem] = []
@ -72,9 +66,8 @@ class MastodonPickServerViewModel: NSObject {
let loadingIndexedServersError = CurrentValueSubject<Error?, Never>(nil)
let emptyStateViewState = CurrentValueSubject<EmptyStateViewState, Never>(.none)
init(context: AppContext, mode: PickServerMode) {
init(context: AppContext) {
self.context = context
self.mode = mode
super.init()
configure()
@ -115,9 +108,7 @@ extension MastodonPickServerViewModel {
.map { indexedServers, selectCategoryItem, searchText -> [Mastodon.Entity.Server] in
// ignore approval required servers when sign-up
var indexedServers = indexedServers
if self.mode == .signUp {
indexedServers = indexedServers.filter { !$0.approvalRequired }
}
indexedServers = indexedServers.filter { !$0.approvalRequired }
// Note:
// sort by calculate last week users count
// and make medium size (~800) server to top

View File

@ -14,14 +14,8 @@ import Kanna
import MastodonAsset
import MastodonLocalization
protocol PickServerCellDelegate: AnyObject {
// func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton)
}
class PickServerCell: UITableViewCell {
weak var delegate: PickServerCellDelegate?
var disposeBag = Set<AnyCancellable>()
let containerView: UIStackView = {
@ -88,7 +82,7 @@ class PickServerCell: UITableViewCell {
label.adjustsFontForContentSizeCategory = true
return label
}()
private var collapseConstraints: [NSLayoutConstraint] = []
private var expandConstraints: [NSLayoutConstraint] = []

View File

@ -19,13 +19,6 @@ class PickServerCategoryView: UIView {
return view
}()
let emojiLabel: UILabel = {
let label = UILabel()
label.textAlignment = .center
label.font = .systemFont(ofSize: 34, weight: .regular)
return label
}()
let titleLabel: UILabel = {
let label = UILabel()
label.textAlignment = .center
@ -50,6 +43,7 @@ extension PickServerCategoryView {
private func configure() {
let container = UIStackView()
container.axis = .vertical
container.spacing = 2
container.distribution = .fillProportionally
container.translatesAutoresizingMaskIntoConstraints = false
@ -61,12 +55,11 @@ extension PickServerCategoryView {
container.bottomAnchor.constraint(equalTo: bottomAnchor),
])
container.addArrangedSubview(emojiLabel)
container.addArrangedSubview(titleLabel)
highlightedIndicatorView.translatesAutoresizingMaskIntoConstraints = false
container.addArrangedSubview(highlightedIndicatorView)
NSLayoutConstraint.activate([
highlightedIndicatorView.heightAnchor.constraint(equalToConstant: 3).priority(.required - 1),
highlightedIndicatorView.heightAnchor.constraint(equalToConstant: 3)//.priority(.required - 1),
])
titleLabel.setContentHuggingPriority(.required - 1, for: .vertical)
}

View File

@ -19,7 +19,7 @@ protocol PickServerServerSectionTableHeaderViewDelegate: AnyObject {
final class PickServerServerSectionTableHeaderView: UIView {
static let collectionViewHeight: CGFloat = 88
static let collectionViewHeight: CGFloat = 30
static let searchTextFieldHeight: CGFloat = 38
static let spacing: CGFloat = 11
@ -177,7 +177,6 @@ extension PickServerServerSectionTableHeaderView {
extension PickServerServerSectionTableHeaderView: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription)
collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally)
delegate?.pickServerServerSectionTableHeaderView(self, collectionView: collectionView, didSelectItemAt: indexPath)
}
@ -205,5 +204,5 @@ extension PickServerServerSectionTableHeaderView: UITextFieldDelegate {
textField.resignFirstResponder()
return false
}
}

View File

@ -1,116 +0,0 @@
//
// MastodonRegisterAvatarTableViewCell.swift
// Mastodon
//
// Created by MainasuK on 2022-1-5.
//
import UIKit
import Combine
import MastodonAsset
import MastodonLocalization
final class MastodonRegisterAvatarTableViewCell: UITableViewCell {
static let containerSize = CGSize(width: 88, height: 88)
var disposeBag = Set<AnyCancellable>()
let containerView: UIView = {
let view = UIView()
view.backgroundColor = .clear
view.layer.masksToBounds = true
view.layer.cornerCurve = .continuous
view.layer.cornerRadius = 22
return view
}()
let avatarButton: HighlightDimmableButton = {
let button = HighlightDimmableButton()
button.backgroundColor = Asset.Theme.Mastodon.secondaryGroupedSystemBackground.color
button.setImage(Asset.Scene.Onboarding.avatarPlaceholder.image, for: .normal)
return button
}()
let editBannerView: UIView = {
let bannerView = UIView()
bannerView.backgroundColor = UIColor.black.withAlphaComponent(0.5)
bannerView.isUserInteractionEnabled = false
let label: UILabel = {
let label = UILabel()
label.textColor = .white
label.text = L10n.Common.Controls.Actions.edit
label.font = .systemFont(ofSize: 13, weight: .semibold)
label.textAlignment = .center
label.minimumScaleFactor = 0.5
label.adjustsFontSizeToFitWidth = true
return label
}()
label.translatesAutoresizingMaskIntoConstraints = false
bannerView.addSubview(label)
NSLayoutConstraint.activate([
label.topAnchor.constraint(equalTo: bannerView.topAnchor),
label.leadingAnchor.constraint(equalTo: bannerView.leadingAnchor),
label.trailingAnchor.constraint(equalTo: bannerView.trailingAnchor),
label.bottomAnchor.constraint(equalTo: bannerView.bottomAnchor),
])
return bannerView
}()
override func prepareForReuse() {
super.prepareForReuse()
disposeBag.removeAll()
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension MastodonRegisterAvatarTableViewCell {
private func _init() {
selectionStyle = .none
backgroundColor = .clear
containerView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(containerView)
NSLayoutConstraint.activate([
containerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 22),
containerView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
contentView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 8),
containerView.widthAnchor.constraint(equalToConstant: MastodonRegisterAvatarTableViewCell.containerSize.width).priority(.required - 1),
containerView.heightAnchor.constraint(equalToConstant: MastodonRegisterAvatarTableViewCell.containerSize.height).priority(.required - 1),
])
avatarButton.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(avatarButton)
NSLayoutConstraint.activate([
avatarButton.topAnchor.constraint(equalTo: containerView.topAnchor),
avatarButton.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
avatarButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
avatarButton.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
])
editBannerView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(editBannerView)
NSLayoutConstraint.activate([
editBannerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
editBannerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
editBannerView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
editBannerView.heightAnchor.constraint(equalToConstant: 22),
])
}
}

View File

@ -1,50 +0,0 @@
//
// MastodonRegisterPasswordHintTableViewCell.swift
// Mastodon
//
// Created by MainasuK on 2022-1-7.
//
import UIKit
import MastodonAsset
import MastodonLocalization
final class MastodonRegisterPasswordHintTableViewCell: UITableViewCell {
let passwordRuleLabel: UILabel = {
let label = UILabel()
label.font = .preferredFont(forTextStyle: .footnote)
label.textColor = Asset.Colors.Label.secondary.color
label.text = L10n.Scene.Register.Input.Password.hint
return label
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension MastodonRegisterPasswordHintTableViewCell {
private func _init() {
selectionStyle = .none
backgroundColor = .clear
passwordRuleLabel.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(passwordRuleLabel)
NSLayoutConstraint.activate([
passwordRuleLabel.topAnchor.constraint(equalTo: contentView.topAnchor),
passwordRuleLabel.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
passwordRuleLabel.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
passwordRuleLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
}
}

View File

@ -1,142 +0,0 @@
//
// MastodonRegisterTextFieldTableViewCell.swift
// Mastodon
//
// Created by MainasuK on 2022-1-7.
//
import UIKit
import Combine
import MastodonUI
import MastodonAsset
import MastodonLocalization
final class MastodonRegisterTextFieldTableViewCell: UITableViewCell {
static let textFieldHeight: CGFloat = 50
static let textFieldLabelFont = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22)
var disposeBag = Set<AnyCancellable>()
let textFieldShadowContainer = ShadowBackgroundContainer()
let textField: UITextField = {
let textField = UITextField()
textField.font = MastodonRegisterTextFieldTableViewCell.textFieldLabelFont
textField.backgroundColor = Asset.Scene.Onboarding.textFieldBackground.color
textField.layer.masksToBounds = true
textField.layer.cornerRadius = 10
textField.layer.cornerCurve = .continuous
return textField
}()
override func prepareForReuse() {
super.prepareForReuse()
disposeBag.removeAll()
textFieldShadowContainer.shadowColor = .black
textFieldShadowContainer.shadowAlpha = 0.25
resetTextField()
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension MastodonRegisterTextFieldTableViewCell {
private func _init() {
selectionStyle = .none
backgroundColor = .clear
textFieldShadowContainer.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(textFieldShadowContainer)
NSLayoutConstraint.activate([
textFieldShadowContainer.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 6),
textFieldShadowContainer.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
textFieldShadowContainer.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: textFieldShadowContainer.bottomAnchor, constant: 6),
])
textField.translatesAutoresizingMaskIntoConstraints = false
textFieldShadowContainer.addSubview(textField)
NSLayoutConstraint.activate([
textField.topAnchor.constraint(equalTo: textFieldShadowContainer.topAnchor),
textField.leadingAnchor.constraint(equalTo: textFieldShadowContainer.leadingAnchor),
textField.trailingAnchor.constraint(equalTo: textFieldShadowContainer.trailingAnchor),
textField.bottomAnchor.constraint(equalTo: textFieldShadowContainer.bottomAnchor),
textField.heightAnchor.constraint(equalToConstant: MastodonRegisterTextFieldTableViewCell.textFieldHeight).priority(.required - 1),
])
resetTextField()
}
}
extension MastodonRegisterTextFieldTableViewCell {
func resetTextField() {
textField.keyboardType = .default
textField.autocorrectionType = .default
textField.autocapitalizationType = .none
textField.attributedPlaceholder = nil
textField.isSecureTextEntry = false
textField.textAlignment = .natural
textField.semanticContentAttribute = .unspecified
let paddingRect = CGRect(x: 0, y: 0, width: 16, height: 10)
textField.leftView = UIView(frame: paddingRect)
textField.leftViewMode = .always
textField.rightView = UIView(frame: paddingRect)
textField.rightViewMode = .always
}
func setupTextViewRightView(text: String) {
textField.rightView = {
let containerView = UIView()
let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 8, height: MastodonRegisterTextFieldTableViewCell.textFieldHeight))
paddingView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(paddingView)
NSLayoutConstraint.activate([
paddingView.topAnchor.constraint(equalTo: containerView.topAnchor),
paddingView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
paddingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
paddingView.widthAnchor.constraint(equalToConstant: 8).priority(.defaultHigh),
])
let label = UILabel()
label.font = MastodonRegisterTextFieldTableViewCell.textFieldLabelFont
label.textColor = Asset.Colors.Label.primary.color
label.text = text
label.lineBreakMode = .byTruncatingMiddle
label.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(label)
NSLayoutConstraint.activate([
label.topAnchor.constraint(equalTo: containerView.topAnchor),
label.leadingAnchor.constraint(equalTo: paddingView.trailingAnchor),
containerView.trailingAnchor.constraint(equalTo: label.trailingAnchor, constant: 16),
label.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
label.widthAnchor.constraint(lessThanOrEqualToConstant: 180).priority(.required - 1),
])
return containerView
}()
}
func setupTextViewPlaceholder(text: String) {
textField.attributedPlaceholder = NSAttributedString(
string: text,
attributes: [
.foregroundColor: Asset.Colors.Label.secondary.color,
.font: MastodonRegisterTextFieldTableViewCell.textFieldLabelFont
]
)
}
}

View File

@ -168,21 +168,27 @@ struct MastodonRegisterView: View {
func body(content: Content) -> some View {
ZStack {
let shadowColor: Color = {
let borderColor: Color = {
switch validateState {
case .empty: return .black.opacity(0.125)
case .invalid: return Color(Asset.Colors.TextField.invalid.color)
case .valid: return Color(Asset.Colors.TextField.valid.color)
case .empty: return Color(Asset.Scene.Onboarding.textFieldBackground.color)
case .invalid: return Color(Asset.Colors.TextField.invalid.color)
case .valid: return Color(Asset.Scene.Onboarding.textFieldBackground.color)
}
}()
Color(Asset.Scene.Onboarding.textFieldBackground.color)
.cornerRadius(10)
.shadow(color: shadowColor, radius: 1, x: 0, y: 2)
.animation(.easeInOut, value: validateState)
.shadow(color: .black.opacity(0.125), radius: 1, x: 0, y: 2)
content
.padding()
.background(Color(Asset.Scene.Onboarding.textFieldBackground.color))
.cornerRadius(10)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(borderColor, lineWidth: 1)
.animation(.easeInOut, value: validateState)
)
}
}
}

View File

@ -145,7 +145,7 @@ extension MastodonRegisterViewController {
let alertController = UIAlertController(for: error, title: "Sign Up Failure", preferredStyle: .alert)
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
alertController.addAction(okAction)
self.coordinator.present(
_ = self.coordinator.present(
scene: .alertController(alertController: alertController),
from: nil,
transition: .alertController(animated: true, completion: nil)
@ -322,7 +322,7 @@ extension MastodonRegisterViewController {
)
}()
let viewModel = MastodonConfirmEmailViewModel(context: self.context, email: email, authenticateInfo: self.viewModel.authenticateInfo, userToken: userToken, updateCredentialQuery: updateCredentialQuery)
self.coordinator.present(scene: .mastodonConfirmEmail(viewModel: viewModel), from: self, transition: .show)
_ = self.coordinator.present(scene: .mastodonConfirmEmail(viewModel: viewModel), from: self, transition: .show)
}
.store(in: &disposeBag)
}

View File

@ -10,145 +10,6 @@ import Combine
import MastodonAsset
import MastodonLocalization
extension MastodonRegisterViewModel {
func setupDiffableDataSource(
tableView: UITableView
) {
tableView.register(OnboardingHeadlineTableViewCell.self, forCellReuseIdentifier: String(describing: OnboardingHeadlineTableViewCell.self))
tableView.register(MastodonRegisterAvatarTableViewCell.self, forCellReuseIdentifier: String(describing: MastodonRegisterAvatarTableViewCell.self))
tableView.register(MastodonRegisterTextFieldTableViewCell.self, forCellReuseIdentifier: String(describing: MastodonRegisterTextFieldTableViewCell.self))
tableView.register(MastodonRegisterPasswordHintTableViewCell.self, forCellReuseIdentifier: String(describing: MastodonRegisterPasswordHintTableViewCell.self))
diffableDataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item in
switch item {
case .header(let domain):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: OnboardingHeadlineTableViewCell.self), for: indexPath) as! OnboardingHeadlineTableViewCell
cell.titleLabel.text = L10n.Scene.Register.letsGetYouSetUpOnDomain(domain)
cell.subTitleLabel.isHidden = true
return cell
case .avatar:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: MastodonRegisterAvatarTableViewCell.self), for: indexPath) as! MastodonRegisterAvatarTableViewCell
self.configureAvatar(cell: cell)
return cell
case .name:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: MastodonRegisterTextFieldTableViewCell.self), for: indexPath) as! MastodonRegisterTextFieldTableViewCell
cell.setupTextViewPlaceholder(text: L10n.Scene.Register.Input.DisplayName.placeholder)
cell.textField.keyboardType = .default
cell.textField.autocapitalizationType = .words
cell.textField.text = self.name
NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: cell.textField)
.receive(on: DispatchQueue.main)
.compactMap { notification in
guard let textField = notification.object as? UITextField else {
assertionFailure()
return nil
}
return textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
.assign(to: \.name, on: self)
.store(in: &cell.disposeBag)
return cell
case .username:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: MastodonRegisterTextFieldTableViewCell.self), for: indexPath) as! MastodonRegisterTextFieldTableViewCell
cell.setupTextViewRightView(text: "@" + self.domain)
cell.setupTextViewPlaceholder(text: L10n.Scene.Register.Input.Username.placeholder)
cell.textField.keyboardType = .alphabet
cell.textField.autocorrectionType = .no
cell.textField.text = self.username
cell.textField.textAlignment = .left
cell.textField.semanticContentAttribute = .forceLeftToRight
NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: cell.textField)
.receive(on: DispatchQueue.main)
.compactMap { notification in
guard let textField = notification.object as? UITextField else {
assertionFailure()
return nil
}
return textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
.assign(to: \.username, on: self)
.store(in: &cell.disposeBag)
self.configureTextFieldCell(cell: cell, validateState: self.$usernameValidateState)
return cell
case .email:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: MastodonRegisterTextFieldTableViewCell.self), for: indexPath) as! MastodonRegisterTextFieldTableViewCell
cell.setupTextViewPlaceholder(text: L10n.Scene.Register.Input.Email.placeholder)
cell.textField.keyboardType = .emailAddress
cell.textField.autocorrectionType = .no
cell.textField.text = self.email
NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: cell.textField)
.receive(on: DispatchQueue.main)
.compactMap { notification in
guard let textField = notification.object as? UITextField else {
assertionFailure()
return nil
}
return textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
.assign(to: \.email, on: self)
.store(in: &cell.disposeBag)
self.configureTextFieldCell(cell: cell, validateState: self.$emailValidateState)
return cell
case .password:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: MastodonRegisterTextFieldTableViewCell.self), for: indexPath) as! MastodonRegisterTextFieldTableViewCell
cell.setupTextViewPlaceholder(text: L10n.Scene.Register.Input.Password.placeholder)
cell.textField.keyboardType = .alphabet
cell.textField.autocorrectionType = .no
cell.textField.isSecureTextEntry = true
cell.textField.text = self.password
cell.textField.textAlignment = .left
cell.textField.semanticContentAttribute = .forceLeftToRight
NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: cell.textField)
.receive(on: DispatchQueue.main)
.compactMap { notification in
guard let textField = notification.object as? UITextField else {
assertionFailure()
return nil
}
return textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
.assign(to: \.password, on: self)
.store(in: &cell.disposeBag)
self.configureTextFieldCell(cell: cell, validateState: self.$passwordValidateState)
return cell
case .hint:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: MastodonRegisterPasswordHintTableViewCell.self), for: indexPath) as! MastodonRegisterPasswordHintTableViewCell
return cell
case .reason:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: MastodonRegisterTextFieldTableViewCell.self), for: indexPath) as! MastodonRegisterTextFieldTableViewCell
cell.setupTextViewPlaceholder(text: L10n.Scene.Register.Input.Invite.registrationUserInviteRequest)
cell.textField.keyboardType = .default
cell.textField.text = self.reason
NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: cell.textField)
.receive(on: DispatchQueue.main)
.compactMap { notification in
guard let textField = notification.object as? UITextField else {
assertionFailure()
return nil
}
return textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
.assign(to: \.reason, on: self)
.store(in: &cell.disposeBag)
self.configureTextFieldCell(cell: cell, validateState: self.$reasonValidateState)
return cell
default:
assertionFailure()
return UITableViewCell()
}
}
var snapshot = NSDiffableDataSourceSnapshot<RegisterSection, RegisterItem>()
snapshot.appendSections([.main])
snapshot.appendItems([.header(domain: domain)], toSection: .main)
snapshot.appendItems([.avatar, .name, .username, .email, .password, .hint], toSection: .main)
if approvalRequired {
snapshot.appendItems([.reason], toSection: .main)
}
diffableDataSource?.applySnapshot(snapshot, animated: false, completion: nil)
}
}
extension MastodonRegisterViewModel {
private func configureAvatar(cell: MastodonRegisterAvatarTableViewCell) {
self.$avatarImage

View File

@ -127,7 +127,7 @@ extension MastodonServerRulesViewController {
instance: viewModel.instance,
applicationToken: viewModel.applicationToken
)
coordinator.present(scene: .mastodonRegister(viewModel: viewModel), from: self, transition: .show)
_ = coordinator.present(scene: .mastodonRegister(viewModel: viewModel), from: self, transition: .show)
}
}

View File

@ -317,12 +317,12 @@ extension WelcomeViewController {
extension WelcomeViewController {
@objc
private func signUpButtonDidClicked(_ sender: UIButton) {
coordinator.present(scene: .mastodonPickServer(viewMode: MastodonPickServerViewModel(context: context, mode: .signUp)), from: self, transition: .show)
_ = coordinator.present(scene: .mastodonPickServer(viewMode: MastodonPickServerViewModel(context: context)), from: self, transition: .show)
}
@objc
private func signInButtonDidClicked(_ sender: UIButton) {
coordinator.present(scene: .mastodonPickServer(viewMode: MastodonPickServerViewModel(context: context, mode: .signIn)), from: self, transition: .show)
_ = coordinator.present(scene: .mastodonLogin, from: self, transition: .show)
}
@objc

View File

@ -65,11 +65,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
extension AppDelegate {
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
#if DEBUG
return .all
#else
return UIDevice.current.userInterfaceIdiom == .phone ? .portrait : .all
#endif
}
}

View File

@ -32,7 +32,6 @@ let package = Package(
.package(url: "https://github.com/Alamofire/AlamofireImage.git", from: "4.1.0"),
.package(url: "https://github.com/apple/swift-collections.git", from: "1.0.3"),
.package(url: "https://github.com/apple/swift-nio.git", from: "1.0.0"),
.package(url: "https://github.com/cezheng/Fuzi.git", from: "3.1.3"),
.package(url: "https://github.com/Flipboard/FLAnimatedImage.git", from: "1.0.0"),
.package(url: "https://github.com/kean/Nuke-FLAnimatedImage-Plugin.git", from: "8.0.0"),
.package(url: "https://github.com/kean/Nuke.git", from: "10.3.1"),

View File

@ -12,8 +12,8 @@ import MastodonSDK
extension APIService {
public func servers(
language: String?,
category: String?
language: String? = nil,
category: String? = nil
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Server]>, Error> {
let query = Mastodon.API.Onboarding.ServersQuery(language: language, category: category)
return Mastodon.API.Onboarding.servers(session: session, query: query)

View File

@ -665,6 +665,16 @@ public enum L10n {
}
}
}
public enum Login {
/// Log you in with the server where you created your account
public static let subtitle = L10n.tr("Localizable", "Scene.Login.Subtitle", fallback: "Scene.Login.Subtitle")
/// Welcome Back!
public static let title = L10n.tr("Localizable", "Scene.Login.Title", fallback: "Welcome")
public enum ServerSearchField {
/// Search for your server
public static let placeholder = L10n.tr("Localizable", "Scene.Login.ServerSearchField.Placeholder", fallback: "Scene.Login.ServerSearchField.Placeholder")
}
}
public enum Notification {
public enum FollowRequest {
/// Accept

View File

@ -315,6 +315,9 @@ uploaded to Mastodon.";
"Scene.Register.Input.Username.DuplicatePrompt" = "This username is taken.";
"Scene.Register.Input.Username.Placeholder" = "username";
"Scene.Register.LetsGetYouSetUpOnDomain" = "Lets get you set up on %@";
"Scene.Login.Title" = "Welcome Back!";
"Scene.Login.Subtitle" = "Log you in with the server where you created your account";
"Scene.Login.ServerSearchField.Placeholder" = "Search server or enter URL";
"Scene.Register.Title" = "Lets get you set up on %@";
"Scene.Report.Content1" = "Are there any other posts youd like to add to the report?";
"Scene.Report.Content2" = "Is there anything the moderators should know about this report?";
@ -454,4 +457,4 @@ uploaded to Mastodon.";
back in your hands.";
"Scene.Wizard.AccessibilityHint" = "Double tap to dismiss this wizard";
"Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Switch between multiple accounts by holding the profile button.";
"Scene.Wizard.NewInMastodon" = "New in Mastodon";
"Scene.Wizard.NewInMastodon" = "New in Mastodon";

View File

@ -9,7 +9,7 @@ import Foundation
extension Mastodon.Entity {
public struct Server: Codable, Equatable {
public struct Server: Codable, Equatable, Hashable {
public let domain: String
public let version: String
public let description: String