Merge pull request #40 from tootsuite/fix/sign-up-i18n
Make i18n string robust and display server error prompt
This commit is contained in:
commit
460834dfe0
|
@ -1,31 +1,5 @@
|
|||
{
|
||||
"common": {
|
||||
"errors": {
|
||||
"item": {
|
||||
"username": "username",
|
||||
"email": "email",
|
||||
"password": "password",
|
||||
"agreement": "agreement",
|
||||
"locale": "locale",
|
||||
"reason": "reason"
|
||||
},
|
||||
"itemDetail": {
|
||||
"email_invalid": "This is not a valid e-mail address",
|
||||
"username_invalid": "Username must only contain alphanumeric characters and underscores",
|
||||
"password_too_shrot": "password is too short (must be at least 8 characters)",
|
||||
"username_too_long": "username is too long (can't be longer than 30 characters)"
|
||||
},
|
||||
"ERR_BLOCKED": "contains a disallowed e-mail provider",
|
||||
"ERR_UNREACHABLE": "does not seem to exist",
|
||||
"ERR_TAKEN": "is already in use",
|
||||
"ERR_RESERVED": "is a reserved keyword",
|
||||
"ERR_ACCEPTED": "must be accepted",
|
||||
"ERR_BLANK": "is required",
|
||||
"ERR_INVALID": "is invalid",
|
||||
"ERR_TOO_LONG": "is too long",
|
||||
"ERR_TOO_SHORT": "is too short",
|
||||
"ERR_INCLUSION": "is not a supported value"
|
||||
},
|
||||
"alerts": {
|
||||
"sign_up_failure": {
|
||||
"title": "Sign Up Failure"
|
||||
|
@ -33,7 +7,6 @@
|
|||
"server_error": {
|
||||
"title": "Server Error"
|
||||
}
|
||||
|
||||
},
|
||||
"controls": {
|
||||
"actions": {
|
||||
|
@ -91,6 +64,10 @@
|
|||
},
|
||||
"input": {
|
||||
"placeholder": "Find a server or join your own..."
|
||||
},
|
||||
"empty_state": {
|
||||
"finding_servers": "Finding available servers...",
|
||||
"bad_network": "Something went wrong while loading data. Check your internet connection."
|
||||
}
|
||||
},
|
||||
"register": {
|
||||
|
@ -108,14 +85,40 @@
|
|||
},
|
||||
"password": {
|
||||
"placeholder": "password",
|
||||
"hint": "Your password needs at least Eight characters"
|
||||
"hint": "Your password needs at least eight characters"
|
||||
},
|
||||
"invite": {
|
||||
"registration_user_invite_request": "Why do you want to join?"
|
||||
"registration_user_invite_request": "Why do you want to join?"
|
||||
}
|
||||
},
|
||||
"success": "Success",
|
||||
"check_email": "Regsiter request sent. Please check your email."
|
||||
"error": {
|
||||
"item": {
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"agreement": "Agreement",
|
||||
"locale": "Locale",
|
||||
"reason": "Reason"
|
||||
},
|
||||
"reason": {
|
||||
"blocked": "%s contains a disallowed e-mail provider",
|
||||
"unreachable": "%s does not seem to exist",
|
||||
"taken": "%s is already in use",
|
||||
"reserved": "%s is a reserved keyword",
|
||||
"accepted": "%s must be accepted",
|
||||
"blank": "%s is required",
|
||||
"invalid": "%s is invalid",
|
||||
"too_long": "%s is too long",
|
||||
"too_short": "%s is too short",
|
||||
"inclusion": "%s is not a supported value"
|
||||
},
|
||||
"special": {
|
||||
"username_invalid": "Username must only contain alphanumeric characters and underscores",
|
||||
"username_too_long": "Username is too long (can't be longer than 30 characters)",
|
||||
"email_invalid": "This is not a valid e-mail address",
|
||||
"password_too_short": "Password is too short (must be at least 8 characters)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server_rules": {
|
||||
"title": "Some ground rules.",
|
||||
|
|
|
@ -55,7 +55,7 @@
|
|||
2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */; };
|
||||
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; };
|
||||
2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; };
|
||||
2D650FAB25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift */; };
|
||||
2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */; };
|
||||
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; };
|
||||
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */; };
|
||||
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; };
|
||||
|
@ -96,6 +96,12 @@
|
|||
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; };
|
||||
DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; };
|
||||
DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */; };
|
||||
DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */; };
|
||||
DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */; };
|
||||
DB1FD44A25F26CD7004CFCFC /* PickServerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44925F26CD7004CFCFC /* PickServerItem.swift */; };
|
||||
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */; };
|
||||
DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */; };
|
||||
DB1FD46025F278AF004CFCFC /* CategoryPickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */; };
|
||||
DB2B3ABC25E37E15007045F9 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB2B3ABE25E37E15007045F9 /* InfoPlist.strings */; };
|
||||
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; };
|
||||
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; };
|
||||
|
@ -125,6 +131,7 @@
|
|||
DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */; };
|
||||
DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; };
|
||||
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; };
|
||||
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; };
|
||||
DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; };
|
||||
DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; };
|
||||
DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; };
|
||||
|
@ -149,6 +156,7 @@
|
|||
DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */; };
|
||||
DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF55C25C138B7002E6C99 /* UIViewController.swift */; };
|
||||
DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */; };
|
||||
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */; };
|
||||
DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98336A25C9420100AD9700 /* APIService+App.swift */; };
|
||||
DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337025C9443200AD9700 /* APIService+Authentication.swift */; };
|
||||
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; };
|
||||
|
@ -162,6 +170,7 @@
|
|||
DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */; };
|
||||
DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; };
|
||||
DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; };
|
||||
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; };
|
||||
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; };
|
||||
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; };
|
||||
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; };
|
||||
|
@ -264,7 +273,7 @@
|
|||
2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+DebugAction.swift"; sourceTree = "<group>"; };
|
||||
2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = "<group>"; };
|
||||
2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
|
||||
2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entidy+ErrorDetailReason.swift"; sourceTree = "<group>"; };
|
||||
2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = "<group>"; };
|
||||
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
|
||||
2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Toot.swift"; sourceTree = "<group>"; };
|
||||
2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = "<group>"; };
|
||||
|
@ -309,6 +318,12 @@
|
|||
DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = "<group>"; };
|
||||
DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||
DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDimmableButton.swift; sourceTree = "<group>"; };
|
||||
DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+LoadIndexedServerState.swift"; sourceTree = "<group>"; };
|
||||
DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSection.swift; sourceTree = "<group>"; };
|
||||
DB1FD44925F26CD7004CFCFC /* PickServerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PickServerItem.swift; path = Mastodon/Diffiable/Section/PickServerItem.swift; sourceTree = SOURCE_ROOT; };
|
||||
DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||
DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerItem.swift; sourceTree = "<group>"; };
|
||||
DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = "<group>"; };
|
||||
DB2B3ABD25E37E15007045F9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
DB2B3AE825E38850007045F9 /* UIViewPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewPreview.swift; sourceTree = "<group>"; };
|
||||
DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = "<group>"; };
|
||||
|
@ -343,6 +358,7 @@
|
|||
DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DarkContentStatusBarStyleNavigationController.swift; sourceTree = "<group>"; };
|
||||
DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = "<group>"; };
|
||||
DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = "<group>"; };
|
||||
DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = "<group>"; };
|
||||
DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = "<group>"; };
|
||||
DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = "<group>"; };
|
||||
DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
|
@ -369,6 +385,7 @@
|
|||
DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainTabBarController.swift; sourceTree = "<group>"; };
|
||||
DB8AF55C25C138B7002E6C99 /* UIViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = "<group>"; };
|
||||
DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineIndex.swift; sourceTree = "<group>"; };
|
||||
DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerEmptyStateView.swift; sourceTree = "<group>"; };
|
||||
DB98336A25C9420100AD9700 /* APIService+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+App.swift"; sourceTree = "<group>"; };
|
||||
DB98337025C9443200AD9700 /* APIService+Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Authentication.swift"; sourceTree = "<group>"; };
|
||||
DB98337E25C9452D00AD9700 /* APIService+APIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+APIError.swift"; sourceTree = "<group>"; };
|
||||
|
@ -382,6 +399,7 @@
|
|||
DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewModel.swift; sourceTree = "<group>"; };
|
||||
DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; };
|
||||
DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; };
|
||||
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = "<group>"; };
|
||||
DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = "<group>"; };
|
||||
DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = "<group>"; };
|
||||
DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = "<group>"; };
|
||||
|
@ -454,11 +472,14 @@
|
|||
0FAA102525E1125D0017CCDE /* PickServer */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0FB3D31825E525DE00AAD544 /* CollectionViewCell */,
|
||||
0FB3D30D25E525C000AAD544 /* View */,
|
||||
0FB3D31825E525DE00AAD544 /* CollectionViewCell */,
|
||||
0FB3D2FC25E4CB4B00AAD544 /* TableViewCell */,
|
||||
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */,
|
||||
0FAA102625E1126A0017CCDE /* MastodonPickServerViewController.swift */,
|
||||
0FB3D2F625E4C24D00AAD544 /* MastodonPickServerViewModel.swift */,
|
||||
DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */,
|
||||
DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */,
|
||||
);
|
||||
path = PickServer;
|
||||
sourceTree = "<group>";
|
||||
|
@ -478,6 +499,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
0FB3D30E25E525CD00AAD544 /* PickServerCategoryView.swift */,
|
||||
DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */,
|
||||
);
|
||||
path = View;
|
||||
sourceTree = "<group>";
|
||||
|
@ -640,6 +662,8 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
2D76319E25C1521200929FB9 /* StatusSection.swift */,
|
||||
DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */,
|
||||
DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */,
|
||||
);
|
||||
path = Section;
|
||||
sourceTree = "<group>";
|
||||
|
@ -681,6 +705,8 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
2D7631B225C159F700929FB9 /* Item.swift */,
|
||||
DB1FD44925F26CD7004CFCFC /* PickServerItem.swift */,
|
||||
DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */,
|
||||
);
|
||||
path = Item;
|
||||
sourceTree = "<group>";
|
||||
|
@ -885,6 +911,15 @@
|
|||
path = NavigationController;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB6C8C0525F0921200AAA452 /* MastodonSDK */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */,
|
||||
2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */,
|
||||
);
|
||||
path = MastodonSDK;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB72602125E36A2500235243 /* ServerRules */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -1001,6 +1036,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
DB084B5125CBC56300F898ED /* CoreDataStack */,
|
||||
DB6C8C0525F0921200AAA452 /* MastodonSDK */,
|
||||
DB0140CE25C42AEE00F9F3CF /* OSLog.swift */,
|
||||
2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */,
|
||||
DB8AF55C25C138B7002E6C99 /* UIViewController.swift */,
|
||||
|
@ -1015,7 +1051,6 @@
|
|||
DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */,
|
||||
2D32EAB925CB9B0500C9ED86 /* UIView.swift */,
|
||||
0FAA101B25E10E760017CCDE /* UIFont.swift */,
|
||||
2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift */,
|
||||
2D939AB425EDD8A90076FA61 /* String.swift */,
|
||||
);
|
||||
path = Extension;
|
||||
|
@ -1453,8 +1488,10 @@
|
|||
2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */,
|
||||
0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */,
|
||||
2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */,
|
||||
DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */,
|
||||
0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */,
|
||||
DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */,
|
||||
DB1FD44A25F26CD7004CFCFC /* PickServerItem.swift in Sources */,
|
||||
DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */,
|
||||
2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */,
|
||||
2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */,
|
||||
|
@ -1500,11 +1537,14 @@
|
|||
2DF75BA125D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift in Sources */,
|
||||
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */,
|
||||
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */,
|
||||
DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */,
|
||||
2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */,
|
||||
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
|
||||
DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */,
|
||||
DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */,
|
||||
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */,
|
||||
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */,
|
||||
DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */,
|
||||
0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */,
|
||||
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */,
|
||||
DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */,
|
||||
|
@ -1517,14 +1557,18 @@
|
|||
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
|
||||
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
|
||||
2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */,
|
||||
2D650FAB25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift in Sources */,
|
||||
2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */,
|
||||
DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */,
|
||||
DB084B5725CBC56C00F898ED /* Toot.swift in Sources */,
|
||||
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
|
||||
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */,
|
||||
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */,
|
||||
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */,
|
||||
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */,
|
||||
DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */,
|
||||
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */,
|
||||
DB98338725C945ED00AD9700 /* Strings.swift in Sources */,
|
||||
DB1FD46025F278AF004CFCFC /* CategoryPickerSection.swift in Sources */,
|
||||
DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */,
|
||||
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */,
|
||||
2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */,
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>10</integer>
|
||||
<integer>8</integer>
|
||||
</dict>
|
||||
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -22,31 +22,10 @@
|
|||
<key>Mastodon.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
<integer>7</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
<dict>
|
||||
<key>DB427DD125BAA00100D1B89D</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>DB427DE725BAA00100D1B89D</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>DB427DF225BAA00100D1B89D</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>DB89B9F525C10FD0008580ED</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
<dict/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
//
|
||||
// CategoryPickerItem.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021/3/5.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
|
||||
enum CategoryPickerItem {
|
||||
case all
|
||||
case category(category: Mastodon.Entity.Category)
|
||||
}
|
||||
|
||||
extension CategoryPickerItem {
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all:
|
||||
return L10n.Scene.ServerPicker.Button.Category.all
|
||||
case .category(let category):
|
||||
switch category.category {
|
||||
case .academia:
|
||||
return "📚"
|
||||
case .activism:
|
||||
return "✊"
|
||||
case .food:
|
||||
return "🍕"
|
||||
case .furry:
|
||||
return "🦁"
|
||||
case .games:
|
||||
return "🕹"
|
||||
case .general:
|
||||
return "💬"
|
||||
case .journalism:
|
||||
return "📰"
|
||||
case .lgbt:
|
||||
return "🏳️🌈"
|
||||
case .regional:
|
||||
return "📍"
|
||||
case .art:
|
||||
return "🎨"
|
||||
case .music:
|
||||
return "🎼"
|
||||
case .tech:
|
||||
return "📱"
|
||||
case ._other:
|
||||
return "❓"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CategoryPickerItem: Equatable {
|
||||
static func == (lhs: CategoryPickerItem, rhs: CategoryPickerItem) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.all, .all):
|
||||
return true
|
||||
case (.category(let categoryLeft), .category(let categoryRight)):
|
||||
return categoryLeft.category.rawValue == categoryRight.category.rawValue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CategoryPickerItem: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case .all:
|
||||
hasher.combine(String(describing: CategoryPickerItem.all.self))
|
||||
case .category(let category):
|
||||
hasher.combine(category.category.rawValue)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -30,7 +30,7 @@ protocol StatusContentWarningAttribute {
|
|||
}
|
||||
|
||||
extension Item {
|
||||
class StatusTimelineAttribute: Hashable, StatusContentWarningAttribute {
|
||||
class StatusTimelineAttribute: Equatable, Hashable, StatusContentWarningAttribute {
|
||||
var isStatusTextSensitive: Bool
|
||||
var isStatusSensitive: Bool
|
||||
|
||||
|
@ -51,7 +51,6 @@ extension Item {
|
|||
hasher.combine(isStatusTextSensitive)
|
||||
hasher.combine(isStatusSensitive)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
//
|
||||
// CategoryPickerSection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021/3/5.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
enum CategoryPickerSection: Equatable, Hashable {
|
||||
case main
|
||||
}
|
||||
|
||||
extension CategoryPickerSection {
|
||||
static func collectionViewDiffableDataSource(
|
||||
for collectionView: UICollectionView,
|
||||
dependency: NeedsDependency
|
||||
) -> UICollectionViewDiffableDataSource<CategoryPickerSection, CategoryPickerItem> {
|
||||
UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell
|
||||
switch item {
|
||||
case .all:
|
||||
cell.categoryView.titleLabel.font = .systemFont(ofSize: 17)
|
||||
case .category:
|
||||
cell.categoryView.titleLabel.font = .systemFont(ofSize: 28)
|
||||
}
|
||||
cell.categoryView.titleLabel.text = item.title
|
||||
cell.observe(\.isSelected, options: [.initial, .new]) { cell, _ in
|
||||
if cell.isSelected {
|
||||
cell.categoryView.bgView.backgroundColor = Asset.Colors.lightBrandBlue.color
|
||||
cell.categoryView.bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0)
|
||||
if case .all = item {
|
||||
cell.categoryView.titleLabel.textColor = Asset.Colors.lightWhite.color
|
||||
}
|
||||
} else {
|
||||
cell.categoryView.bgView.backgroundColor = Asset.Colors.lightWhite.color
|
||||
cell.categoryView.bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0)
|
||||
if case .all = item {
|
||||
cell.categoryView.titleLabel.textColor = Asset.Colors.lightBrandBlue.color
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cell.observations)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
//
|
||||
// PickServerItem.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021/3/5.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
|
||||
/// Note: update Equatable when change case
|
||||
enum PickServerItem {
|
||||
case header
|
||||
case categoryPicker(items: [CategoryPickerItem])
|
||||
case search
|
||||
case server(server: Mastodon.Entity.Server, attribute: ServerItemAttribute)
|
||||
}
|
||||
|
||||
extension PickServerItem {
|
||||
final class ServerItemAttribute: Equatable, Hashable {
|
||||
var isLast: Bool
|
||||
var isExpand: Bool
|
||||
|
||||
init(isLast: Bool, isExpand: Bool) {
|
||||
self.isLast = isLast
|
||||
self.isExpand = isExpand
|
||||
}
|
||||
|
||||
static func == (lhs: PickServerItem.ServerItemAttribute, rhs: PickServerItem.ServerItemAttribute) -> Bool {
|
||||
return lhs.isExpand == rhs.isExpand
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(isExpand)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PickServerItem: Equatable {
|
||||
static func == (lhs: PickServerItem, rhs: PickServerItem) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.header, .header):
|
||||
return true
|
||||
case (.categoryPicker(let itemsLeft), .categoryPicker(let itemsRight)):
|
||||
return itemsLeft == itemsRight
|
||||
case (.search, .search):
|
||||
return true
|
||||
case (.server(let serverLeft, _), .server(let serverRight, _)):
|
||||
return serverLeft.domain == serverRight.domain
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PickServerItem: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case .header:
|
||||
hasher.combine(String(describing: PickServerItem.header.self))
|
||||
case .categoryPicker(let items):
|
||||
hasher.combine(items)
|
||||
case .search:
|
||||
hasher.combine(String(describing: PickServerItem.search.self))
|
||||
case .server(let server, _):
|
||||
hasher.combine(server.domain)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,133 @@
|
|||
//
|
||||
// PickServerSection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021/3/5.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonSDK
|
||||
import Kanna
|
||||
import AlamofireImage
|
||||
|
||||
enum PickServerSection: Equatable, Hashable {
|
||||
case header
|
||||
case category
|
||||
case search
|
||||
case servers
|
||||
}
|
||||
|
||||
extension PickServerSection {
|
||||
static func tableViewDiffableDataSource(
|
||||
for tableView: UITableView,
|
||||
dependency: NeedsDependency,
|
||||
pickServerCategoriesCellDelegate: PickServerCategoriesCellDelegate,
|
||||
pickServerSearchCellDelegate: PickServerSearchCellDelegate,
|
||||
pickServerCellDelegate: PickServerCellDelegate
|
||||
) -> UITableViewDiffableDataSource<PickServerSection, PickServerItem> {
|
||||
UITableViewDiffableDataSource(tableView: tableView) { [weak pickServerCategoriesCellDelegate, weak pickServerSearchCellDelegate, weak pickServerCellDelegate] tableView, indexPath, item -> UITableViewCell? in
|
||||
switch item {
|
||||
case .header:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell
|
||||
return cell
|
||||
case .categoryPicker(let items):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCategoriesCell.self), for: indexPath) as! PickServerCategoriesCell
|
||||
cell.delegate = pickServerCategoriesCellDelegate
|
||||
cell.diffableDataSource = CategoryPickerSection.collectionViewDiffableDataSource(
|
||||
for: cell.collectionView,
|
||||
dependency: dependency
|
||||
)
|
||||
var snapshot = NSDiffableDataSourceSnapshot<CategoryPickerSection, CategoryPickerItem>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(items, toSection: .main)
|
||||
cell.diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
return cell
|
||||
case .search:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerSearchCell.self), for: indexPath) as! PickServerSearchCell
|
||||
cell.delegate = pickServerSearchCellDelegate
|
||||
return cell
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PickServerSection {
|
||||
|
||||
static func configure(cell: PickServerCell, server: Mastodon.Entity.Server, attribute: PickServerItem.ServerItemAttribute) {
|
||||
cell.domainLabel.text = server.domain
|
||||
cell.descriptionLabel.text = {
|
||||
guard let html = try? HTML(html: server.description, encoding: .utf8) else {
|
||||
return server.description
|
||||
}
|
||||
|
||||
return html.text ?? server.description
|
||||
}()
|
||||
cell.langValueLabel.text = server.language.uppercased()
|
||||
cell.usersValueLabel.text = parseUsersCount(server.totalUsers)
|
||||
cell.categoryValueLabel.text = server.category.uppercased()
|
||||
|
||||
cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse)
|
||||
|
||||
if attribute.isLast {
|
||||
cell.containerView.layer.maskedCorners = [
|
||||
.layerMinXMaxYCorner,
|
||||
.layerMaxXMaxYCorner
|
||||
]
|
||||
cell.containerView.layer.cornerCurve = .continuous
|
||||
cell.containerView.layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius
|
||||
} else {
|
||||
cell.containerView.layer.cornerRadius = 0
|
||||
}
|
||||
|
||||
cell.expandMode
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { mode in
|
||||
switch mode {
|
||||
case .collapse:
|
||||
// do nothing
|
||||
break
|
||||
case .expand:
|
||||
let placeholderImage = UIImage.placeholder(size: cell.thumbnailImageView.frame.size, color: .systemFill)
|
||||
.af.imageRounded(withCornerRadius: 3.0, divideRadiusByImageScale: false)
|
||||
guard let proxiedThumbnail = server.proxiedThumbnail,
|
||||
let url = URL(string: proxiedThumbnail) else {
|
||||
cell.thumbnailImageView.image = placeholderImage
|
||||
cell.thumbnailActivityIdicator.stopAnimating()
|
||||
return
|
||||
}
|
||||
cell.thumbnailImageView.isHidden = false
|
||||
cell.thumbnailActivityIdicator.startAnimating()
|
||||
|
||||
cell.thumbnailImageView.af.setImage(
|
||||
withURL: url,
|
||||
placeholderImage: placeholderImage,
|
||||
filter: AspectScaledToFillSizeWithRoundedCornersFilter(size: cell.thumbnailImageView.frame.size, radius: 3),
|
||||
imageTransition: .crossDissolve(0.33),
|
||||
completion: { [weak cell] response in
|
||||
switch response.result {
|
||||
case .success, .failure:
|
||||
cell?.thumbnailActivityIdicator.stopAnimating()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
}
|
||||
|
||||
private static func parseUsersCount(_ usersCount: Int) -> String {
|
||||
switch usersCount {
|
||||
case 0..<1000:
|
||||
return "\(usersCount)"
|
||||
default:
|
||||
let usersCountInThousand = Float(usersCount) / 1000.0
|
||||
return String(format: "%.1fK", usersCountInThousand)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,99 +0,0 @@
|
|||
//
|
||||
// Mastodon+Entity+ErrorDetailReason.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/1.
|
||||
//
|
||||
import MastodonSDK
|
||||
|
||||
extension Mastodon.Entity.ErrorDetailReason {
|
||||
func localizedDescription() -> String {
|
||||
switch self.error {
|
||||
case .ERR_BLOCKED:
|
||||
return L10n.Common.Errors.errBlocked
|
||||
case .ERR_UNREACHABLE:
|
||||
return L10n.Common.Errors.errUnreachable
|
||||
case .ERR_TAKEN:
|
||||
return L10n.Common.Errors.errTaken
|
||||
case .ERR_RESERVED:
|
||||
return L10n.Common.Errors.errReserved
|
||||
case .ERR_ACCEPTED:
|
||||
return L10n.Common.Errors.errAccepted
|
||||
case .ERR_BLANK:
|
||||
return L10n.Common.Errors.errBlank
|
||||
case .ERR_INVALID:
|
||||
return L10n.Common.Errors.errInvalid
|
||||
case .ERR_TOO_LONG:
|
||||
return L10n.Common.Errors.errTooLong
|
||||
case .ERR_TOO_SHORT:
|
||||
return L10n.Common.Errors.errTooShort
|
||||
case .ERR_INCLUSION:
|
||||
return L10n.Common.Errors.errInclusion
|
||||
case ._other:
|
||||
return self.errorDescription ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Mastodon.Entity.ErrorDetail {
|
||||
func localizedDescription() -> String {
|
||||
var messages: [String?] = []
|
||||
|
||||
if let username = self.username, !username.isEmpty {
|
||||
let errors = username.map { errorDetailReason -> String in
|
||||
switch errorDetailReason.error {
|
||||
case .ERR_INVALID:
|
||||
return L10n.Common.Errors.Itemdetail.usernameInvalid
|
||||
case .ERR_TOO_LONG:
|
||||
return L10n.Common.Errors.Itemdetail.usernameTooLong
|
||||
default:
|
||||
return L10n.Common.Errors.Item.username + " " + errorDetailReason.localizedDescription()
|
||||
}
|
||||
}
|
||||
messages.append(contentsOf: errors)
|
||||
}
|
||||
|
||||
if let email = self.email, !email.isEmpty {
|
||||
let errors = email.map { errorDetailReason -> String in
|
||||
if errorDetailReason.error == .ERR_INVALID {
|
||||
return L10n.Common.Errors.Itemdetail.emailInvalid
|
||||
} else {
|
||||
return L10n.Common.Errors.Item.email + " " + errorDetailReason.localizedDescription()
|
||||
}
|
||||
}
|
||||
messages.append(contentsOf: errors)
|
||||
}
|
||||
if let password = self.password,!password.isEmpty {
|
||||
let errors = password.map { errorDetailReason -> String in
|
||||
if errorDetailReason.error == .ERR_TOO_SHORT {
|
||||
return L10n.Common.Errors.Itemdetail.passwordTooShrot
|
||||
} else {
|
||||
return L10n.Common.Errors.Item.password + " " + errorDetailReason.localizedDescription()
|
||||
}
|
||||
}
|
||||
messages.append(contentsOf: errors)
|
||||
}
|
||||
if let agreement = self.agreement, !agreement.isEmpty {
|
||||
let errors = agreement.map {
|
||||
L10n.Common.Errors.Item.agreement + " " + $0.localizedDescription()
|
||||
}
|
||||
messages.append(contentsOf: errors)
|
||||
}
|
||||
if let locale = self.locale, !locale.isEmpty {
|
||||
let errors = locale.map {
|
||||
L10n.Common.Errors.Item.locale + " " + $0.localizedDescription()
|
||||
}
|
||||
messages.append(contentsOf: errors)
|
||||
}
|
||||
if let reason = self.reason, !reason.isEmpty {
|
||||
let errors = reason.map {
|
||||
L10n.Common.Errors.Item.reason + " " + $0.localizedDescription()
|
||||
}
|
||||
messages.append(contentsOf: errors)
|
||||
}
|
||||
let message = messages
|
||||
.compactMap { $0 }
|
||||
.joined(separator: ", ")
|
||||
return message.capitalizingFirstLetter()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,112 @@
|
|||
//
|
||||
// Mastodon+Entity+ErrorDetailReason.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/1.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
|
||||
extension Mastodon.Entity.Error.Detail: LocalizedError {
|
||||
|
||||
public var failureReason: String? {
|
||||
let reasons: [[String]] = [
|
||||
usernameErrorDescriptions,
|
||||
emailErrorDescriptions,
|
||||
passwordErrorDescriptions,
|
||||
agreementErrorDescriptions,
|
||||
localeErrorDescriptions,
|
||||
reasonErrorDescriptions,
|
||||
]
|
||||
|
||||
guard !reasons.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return reasons
|
||||
.flatMap { $0 }
|
||||
.joined(separator: "; ")
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Mastodon.Entity.Error.Detail {
|
||||
|
||||
enum Item: String {
|
||||
case username
|
||||
case email
|
||||
case password
|
||||
case agreement
|
||||
case locale
|
||||
case reason
|
||||
|
||||
var localized: String {
|
||||
switch self {
|
||||
case .username: return L10n.Scene.Register.Error.Item.username
|
||||
case .email: return L10n.Scene.Register.Error.Item.email
|
||||
case .password: return L10n.Scene.Register.Error.Item.password
|
||||
case .agreement: return L10n.Scene.Register.Error.Item.agreement
|
||||
case .locale: return L10n.Scene.Register.Error.Item.locale
|
||||
case .reason: return L10n.Scene.Register.Error.Item.reason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func localizeError(item: Item, for reason: Reason) -> String {
|
||||
switch (item, reason.error) {
|
||||
case (.username, .ERR_INVALID):
|
||||
return L10n.Scene.Register.Error.Special.usernameInvalid
|
||||
case (.username, .ERR_TOO_LONG):
|
||||
return L10n.Scene.Register.Error.Special.usernameTooLong
|
||||
case (.email, .ERR_INVALID):
|
||||
return L10n.Scene.Register.Error.Special.emailInvalid
|
||||
case (.password, .ERR_TOO_SHORT):
|
||||
return L10n.Scene.Register.Error.Special.passwordTooShort
|
||||
case (_, .ERR_BLOCKED): return L10n.Scene.Register.Error.Reason.blocked(item.localized)
|
||||
case (_, .ERR_UNREACHABLE): return L10n.Scene.Register.Error.Reason.unreachable(item.localized)
|
||||
case (_, .ERR_TAKEN): return L10n.Scene.Register.Error.Reason.taken(item.localized)
|
||||
case (_, .ERR_RESERVED): return L10n.Scene.Register.Error.Reason.reserved(item.localized)
|
||||
case (_, .ERR_ACCEPTED): return L10n.Scene.Register.Error.Reason.accepted(item.localized)
|
||||
case (_, .ERR_BLANK): return L10n.Scene.Register.Error.Reason.blank(item.localized)
|
||||
case (_, .ERR_INVALID): return L10n.Scene.Register.Error.Reason.invalid(item.localized)
|
||||
case (_, .ERR_TOO_LONG): return L10n.Scene.Register.Error.Reason.tooLong(item.localized)
|
||||
case (_, .ERR_TOO_SHORT): return L10n.Scene.Register.Error.Reason.tooShort(item.localized)
|
||||
case (_, .ERR_INCLUSION): return L10n.Scene.Register.Error.Reason.inclusion(item.localized)
|
||||
case (_, ._other(let reason)):
|
||||
assertionFailure("Needs handle new error description here")
|
||||
return item.rawValue + " " + reason.description
|
||||
}
|
||||
}
|
||||
|
||||
var usernameErrorDescriptions: [String] {
|
||||
guard let username = username, !username.isEmpty else { return [] }
|
||||
return username.map { Mastodon.Entity.Error.Detail.localizeError(item: .username, for: $0) }
|
||||
}
|
||||
|
||||
var emailErrorDescriptions: [String] {
|
||||
guard let email = email, !email.isEmpty else { return [] }
|
||||
return email.map { Mastodon.Entity.Error.Detail.localizeError(item: .email, for: $0) }
|
||||
}
|
||||
|
||||
var passwordErrorDescriptions: [String] {
|
||||
guard let password = password, !password.isEmpty else { return [] }
|
||||
return password.map { Mastodon.Entity.Error.Detail.localizeError(item: .password, for: $0) }
|
||||
}
|
||||
|
||||
var agreementErrorDescriptions: [String] {
|
||||
guard let agreement = agreement, !agreement.isEmpty else { return [] }
|
||||
return agreement.map { Mastodon.Entity.Error.Detail.localizeError(item: .agreement, for: $0) }
|
||||
}
|
||||
|
||||
var localeErrorDescriptions: [String] {
|
||||
guard let locale = locale, !locale.isEmpty else { return [] }
|
||||
return locale.map { Mastodon.Entity.Error.Detail.localizeError(item: .locale, for: $0) }
|
||||
}
|
||||
|
||||
var reasonErrorDescriptions: [String] {
|
||||
guard let reason = reason, !reason.isEmpty else { return [] }
|
||||
return reason.map { Mastodon.Entity.Error.Detail.localizeError(item: .reason, for: $0) }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
//
|
||||
// Mastodon+Entity+Error.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-4.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
|
||||
extension Mastodon.API.Error: LocalizedError {
|
||||
|
||||
public var errorDescription: String? {
|
||||
guard let mastodonError = mastodonError else {
|
||||
return "HTTP \(httpResponseStatus.code)"
|
||||
}
|
||||
switch mastodonError {
|
||||
case .generic(let error):
|
||||
if let _ = error.details {
|
||||
return nil // Duplicated with the details
|
||||
} else {
|
||||
return error.error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var failureReason: String? {
|
||||
guard let mastodonError = mastodonError else {
|
||||
return httpResponseStatus.reasonPhrase
|
||||
}
|
||||
switch mastodonError {
|
||||
case .generic(let error):
|
||||
if let details = error.details {
|
||||
return details.failureReason
|
||||
} else {
|
||||
return error.errorDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -42,44 +42,3 @@ extension UIAlertController {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIAlertController {
|
||||
convenience init(
|
||||
for error: Mastodon.API.Error,
|
||||
title: String?,
|
||||
preferredStyle: UIAlertController.Style
|
||||
) {
|
||||
let _title: String
|
||||
let message: String?
|
||||
switch error.mastodonError {
|
||||
case .generic(let mastodonEntityError):
|
||||
|
||||
if let title = title {
|
||||
_title = title
|
||||
} else {
|
||||
_title = error.errorDescription ?? "Error"
|
||||
}
|
||||
var messages: [String?] = []
|
||||
if let details = mastodonEntityError.details {
|
||||
message = details.localizedDescription()
|
||||
} else {
|
||||
messages.append(contentsOf: [
|
||||
error.failureReason,
|
||||
error.recoverySuggestion
|
||||
])
|
||||
message = messages
|
||||
.compactMap { $0 }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
default:
|
||||
_title = "Internal Error"
|
||||
message = error.localizedDescription
|
||||
}
|
||||
|
||||
self.init(
|
||||
title: _title,
|
||||
message: message,
|
||||
preferredStyle: preferredStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -82,52 +82,6 @@ internal enum L10n {
|
|||
internal static let single = L10n.tr("Localizable", "Common.Countable.Photo.Single")
|
||||
}
|
||||
}
|
||||
internal enum Errors {
|
||||
/// must be accepted
|
||||
internal static let errAccepted = L10n.tr("Localizable", "Common.Errors.ErrAccepted")
|
||||
/// is required
|
||||
internal static let errBlank = L10n.tr("Localizable", "Common.Errors.ErrBlank")
|
||||
/// contains a disallowed e-mail provider
|
||||
internal static let errBlocked = L10n.tr("Localizable", "Common.Errors.ErrBlocked")
|
||||
/// is not a supported value
|
||||
internal static let errInclusion = L10n.tr("Localizable", "Common.Errors.ErrInclusion")
|
||||
/// is invalid
|
||||
internal static let errInvalid = L10n.tr("Localizable", "Common.Errors.ErrInvalid")
|
||||
/// is a reserved keyword
|
||||
internal static let errReserved = L10n.tr("Localizable", "Common.Errors.ErrReserved")
|
||||
/// is already in use
|
||||
internal static let errTaken = L10n.tr("Localizable", "Common.Errors.ErrTaken")
|
||||
/// is too long
|
||||
internal static let errTooLong = L10n.tr("Localizable", "Common.Errors.ErrTooLong")
|
||||
/// is too short
|
||||
internal static let errTooShort = L10n.tr("Localizable", "Common.Errors.ErrTooShort")
|
||||
/// does not seem to exist
|
||||
internal static let errUnreachable = L10n.tr("Localizable", "Common.Errors.ErrUnreachable")
|
||||
internal enum Item {
|
||||
/// agreement
|
||||
internal static let agreement = L10n.tr("Localizable", "Common.Errors.Item.Agreement")
|
||||
/// email
|
||||
internal static let email = L10n.tr("Localizable", "Common.Errors.Item.Email")
|
||||
/// locale
|
||||
internal static let locale = L10n.tr("Localizable", "Common.Errors.Item.Locale")
|
||||
/// password
|
||||
internal static let password = L10n.tr("Localizable", "Common.Errors.Item.Password")
|
||||
/// reason
|
||||
internal static let reason = L10n.tr("Localizable", "Common.Errors.Item.Reason")
|
||||
/// username
|
||||
internal static let username = L10n.tr("Localizable", "Common.Errors.Item.Username")
|
||||
}
|
||||
internal enum Itemdetail {
|
||||
/// This is not a valid e-mail address
|
||||
internal static let emailInvalid = L10n.tr("Localizable", "Common.Errors.Itemdetail.EmailInvalid")
|
||||
/// password is too short (must be at least 8 characters)
|
||||
internal static let passwordTooShrot = L10n.tr("Localizable", "Common.Errors.Itemdetail.PasswordTooShrot")
|
||||
/// Username must only contain alphanumeric characters and underscores
|
||||
internal static let usernameInvalid = L10n.tr("Localizable", "Common.Errors.Itemdetail.UsernameInvalid")
|
||||
/// username is too long (can't be longer than 30 characters)
|
||||
internal static let usernameTooLong = L10n.tr("Localizable", "Common.Errors.Itemdetail.UsernameTooLong")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal enum Scene {
|
||||
|
@ -172,12 +126,76 @@ internal enum L10n {
|
|||
internal static let title = L10n.tr("Localizable", "Scene.PublicTimeline.Title")
|
||||
}
|
||||
internal enum Register {
|
||||
/// Regsiter request sent. Please check your email.
|
||||
internal static let checkEmail = L10n.tr("Localizable", "Scene.Register.CheckEmail")
|
||||
/// Success
|
||||
internal static let success = L10n.tr("Localizable", "Scene.Register.Success")
|
||||
/// Tell us about you.
|
||||
internal static let title = L10n.tr("Localizable", "Scene.Register.Title")
|
||||
internal enum Error {
|
||||
internal enum Item {
|
||||
/// Agreement
|
||||
internal static let agreement = L10n.tr("Localizable", "Scene.Register.Error.Item.Agreement")
|
||||
/// Email
|
||||
internal static let email = L10n.tr("Localizable", "Scene.Register.Error.Item.Email")
|
||||
/// Locale
|
||||
internal static let locale = L10n.tr("Localizable", "Scene.Register.Error.Item.Locale")
|
||||
/// Password
|
||||
internal static let password = L10n.tr("Localizable", "Scene.Register.Error.Item.Password")
|
||||
/// Reason
|
||||
internal static let reason = L10n.tr("Localizable", "Scene.Register.Error.Item.Reason")
|
||||
/// Username
|
||||
internal static let username = L10n.tr("Localizable", "Scene.Register.Error.Item.Username")
|
||||
}
|
||||
internal enum Reason {
|
||||
/// %@ must be accepted
|
||||
internal static func accepted(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Scene.Register.Error.Reason.Accepted", String(describing: p1))
|
||||
}
|
||||
/// %@ is required
|
||||
internal static func blank(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Scene.Register.Error.Reason.Blank", String(describing: p1))
|
||||
}
|
||||
/// %@ contains a disallowed e-mail provider
|
||||
internal static func blocked(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Scene.Register.Error.Reason.Blocked", String(describing: p1))
|
||||
}
|
||||
/// %@ is not a supported value
|
||||
internal static func inclusion(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Scene.Register.Error.Reason.Inclusion", String(describing: p1))
|
||||
}
|
||||
/// %@ is invalid
|
||||
internal static func invalid(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Scene.Register.Error.Reason.Invalid", String(describing: p1))
|
||||
}
|
||||
/// %@ is a reserved keyword
|
||||
internal static func reserved(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Scene.Register.Error.Reason.Reserved", String(describing: p1))
|
||||
}
|
||||
/// %@ is already in use
|
||||
internal static func taken(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Scene.Register.Error.Reason.Taken", String(describing: p1))
|
||||
}
|
||||
/// %@ is too long
|
||||
internal static func tooLong(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Scene.Register.Error.Reason.TooLong", String(describing: p1))
|
||||
}
|
||||
/// %@ is too short
|
||||
internal static func tooShort(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Scene.Register.Error.Reason.TooShort", String(describing: p1))
|
||||
}
|
||||
/// %@ does not seem to exist
|
||||
internal static func unreachable(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "Scene.Register.Error.Reason.Unreachable", String(describing: p1))
|
||||
}
|
||||
}
|
||||
internal enum Special {
|
||||
/// This is not a valid e-mail address
|
||||
internal static let emailInvalid = L10n.tr("Localizable", "Scene.Register.Error.Special.EmailInvalid")
|
||||
/// Password is too short (must be at least 8 characters)
|
||||
internal static let passwordTooShort = L10n.tr("Localizable", "Scene.Register.Error.Special.PasswordTooShort")
|
||||
/// Username must only contain alphanumeric characters and underscores
|
||||
internal static let usernameInvalid = L10n.tr("Localizable", "Scene.Register.Error.Special.UsernameInvalid")
|
||||
/// Username is too long (can't be longer than 30 characters)
|
||||
internal static let usernameTooLong = L10n.tr("Localizable", "Scene.Register.Error.Special.UsernameTooLong")
|
||||
}
|
||||
}
|
||||
internal enum Input {
|
||||
internal enum DisplayName {
|
||||
/// display name
|
||||
|
@ -192,7 +210,7 @@ internal enum L10n {
|
|||
internal static let registrationUserInviteRequest = L10n.tr("Localizable", "Scene.Register.Input.Invite.RegistrationUserInviteRequest")
|
||||
}
|
||||
internal enum Password {
|
||||
/// Your password needs at least Eight characters
|
||||
/// Your password needs at least eight characters
|
||||
internal static let hint = L10n.tr("Localizable", "Scene.Register.Input.Password.Hint")
|
||||
/// password
|
||||
internal static let placeholder = L10n.tr("Localizable", "Scene.Register.Input.Password.Placeholder")
|
||||
|
@ -218,6 +236,12 @@ internal enum L10n {
|
|||
internal static let all = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.All")
|
||||
}
|
||||
}
|
||||
internal enum EmptyState {
|
||||
/// Something went wrong while loading data. Check your internet connection.
|
||||
internal static let badNetwork = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.BadNetwork")
|
||||
/// Finding available servers...
|
||||
internal static let findingServers = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.FindingServers")
|
||||
}
|
||||
internal enum Input {
|
||||
/// Find a server or join your own...
|
||||
internal static let placeholder = L10n.tr("Localizable", "Scene.ServerPicker.Input.Placeholder")
|
||||
|
|
|
@ -23,26 +23,6 @@
|
|||
"Common.Controls.Timeline.LoadMore" = "Load More";
|
||||
"Common.Countable.Photo.Multiple" = "photos";
|
||||
"Common.Countable.Photo.Single" = "photo";
|
||||
"Common.Errors.ErrAccepted" = "must be accepted";
|
||||
"Common.Errors.ErrBlank" = "is required";
|
||||
"Common.Errors.ErrBlocked" = "contains a disallowed e-mail provider";
|
||||
"Common.Errors.ErrInclusion" = "is not a supported value";
|
||||
"Common.Errors.ErrInvalid" = "is invalid";
|
||||
"Common.Errors.ErrReserved" = "is a reserved keyword";
|
||||
"Common.Errors.ErrTaken" = "is already in use";
|
||||
"Common.Errors.ErrTooLong" = "is too long";
|
||||
"Common.Errors.ErrTooShort" = "is too short";
|
||||
"Common.Errors.ErrUnreachable" = "does not seem to exist";
|
||||
"Common.Errors.Item.Agreement" = "agreement";
|
||||
"Common.Errors.Item.Email" = "email";
|
||||
"Common.Errors.Item.Locale" = "locale";
|
||||
"Common.Errors.Item.Password" = "password";
|
||||
"Common.Errors.Item.Reason" = "reason";
|
||||
"Common.Errors.Item.Username" = "username";
|
||||
"Common.Errors.Itemdetail.EmailInvalid" = "This is not a valid e-mail address";
|
||||
"Common.Errors.Itemdetail.PasswordTooShrot" = "password is too short (must be at least 8 characters)";
|
||||
"Common.Errors.Itemdetail.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores";
|
||||
"Common.Errors.Itemdetail.UsernameTooLong" = "username is too long (can't be longer than 30 characters)";
|
||||
"Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email";
|
||||
"Scene.ConfirmEmail.Button.OpenEmailApp" = "Open Email App";
|
||||
"Scene.ConfirmEmail.DontReceiveEmail.Description" = "Check if your email address is correct as well as your junk folder if you haven’t.";
|
||||
|
@ -57,19 +37,39 @@ tap the link to confirm your account.";
|
|||
"Scene.ConfirmEmail.Title" = "One last thing.";
|
||||
"Scene.HomeTimeline.Title" = "Home";
|
||||
"Scene.PublicTimeline.Title" = "Public";
|
||||
"Scene.Register.CheckEmail" = "Regsiter request sent. Please check your email.";
|
||||
"Scene.Register.Error.Item.Agreement" = "Agreement";
|
||||
"Scene.Register.Error.Item.Email" = "Email";
|
||||
"Scene.Register.Error.Item.Locale" = "Locale";
|
||||
"Scene.Register.Error.Item.Password" = "Password";
|
||||
"Scene.Register.Error.Item.Reason" = "Reason";
|
||||
"Scene.Register.Error.Item.Username" = "Username";
|
||||
"Scene.Register.Error.Reason.Accepted" = "%@ must be accepted";
|
||||
"Scene.Register.Error.Reason.Blank" = "%@ is required";
|
||||
"Scene.Register.Error.Reason.Blocked" = "%@ contains a disallowed e-mail provider";
|
||||
"Scene.Register.Error.Reason.Inclusion" = "%@ is not a supported value";
|
||||
"Scene.Register.Error.Reason.Invalid" = "%@ is invalid";
|
||||
"Scene.Register.Error.Reason.Reserved" = "%@ is a reserved keyword";
|
||||
"Scene.Register.Error.Reason.Taken" = "%@ is already in use";
|
||||
"Scene.Register.Error.Reason.TooLong" = "%@ is too long";
|
||||
"Scene.Register.Error.Reason.TooShort" = "%@ is too short";
|
||||
"Scene.Register.Error.Reason.Unreachable" = "%@ does not seem to exist";
|
||||
"Scene.Register.Error.Special.EmailInvalid" = "This is not a valid e-mail address";
|
||||
"Scene.Register.Error.Special.PasswordTooShort" = "Password is too short (must be at least 8 characters)";
|
||||
"Scene.Register.Error.Special.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores";
|
||||
"Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can't be longer than 30 characters)";
|
||||
"Scene.Register.Input.DisplayName.Placeholder" = "display name";
|
||||
"Scene.Register.Input.Email.Placeholder" = "email";
|
||||
"Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "Why do you want to join?";
|
||||
"Scene.Register.Input.Password.Hint" = "Your password needs at least Eight characters";
|
||||
"Scene.Register.Input.Password.Hint" = "Your password needs at least eight characters";
|
||||
"Scene.Register.Input.Password.Placeholder" = "password";
|
||||
"Scene.Register.Input.Username.DuplicatePrompt" = "This username is taken.";
|
||||
"Scene.Register.Input.Username.Placeholder" = "username";
|
||||
"Scene.Register.Success" = "Success";
|
||||
"Scene.Register.Title" = "Tell us about you.";
|
||||
"Scene.ServerPicker.Button.Category.All" = "All";
|
||||
"Scene.ServerPicker.Button.Seeless" = "See Less";
|
||||
"Scene.ServerPicker.Button.Seemore" = "See More";
|
||||
"Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading data. Check your internet connection.";
|
||||
"Scene.ServerPicker.EmptyState.FindingServers" = "Finding available servers...";
|
||||
"Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own...";
|
||||
"Scene.ServerPicker.Label.Category" = "CATEGORY";
|
||||
"Scene.ServerPicker.Label.Language" = "LANGUAGE";
|
||||
|
|
|
@ -9,11 +9,7 @@ import UIKit
|
|||
|
||||
class PickServerCategoryCollectionViewCell: UICollectionViewCell {
|
||||
|
||||
var category: MastodonPickServerViewModel.Category? {
|
||||
didSet {
|
||||
categoryView.category = category
|
||||
}
|
||||
}
|
||||
var observations = Set<NSKeyValueObservation>()
|
||||
|
||||
var categoryView: PickServerCategoryView = {
|
||||
let view = PickServerCategoryView()
|
||||
|
@ -21,10 +17,9 @@ class PickServerCategoryCollectionViewCell: UICollectionViewCell {
|
|||
return view
|
||||
}()
|
||||
|
||||
override var isSelected: Bool {
|
||||
didSet {
|
||||
categoryView.selected = isSelected
|
||||
}
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
observations.removeAll()
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
//
|
||||
// MastodonPickServerAppearance.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021/3/6.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
enum MastodonPickServerAppearance {
|
||||
static let tableViewCornerRadius: CGFloat = 10
|
||||
}
|
|
@ -5,31 +5,26 @@
|
|||
// Created by BradGao on 2021/2/20.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import OSLog
|
||||
import MastodonSDK
|
||||
|
||||
final class MastodonPickServerViewController: UIViewController, NeedsDependency {
|
||||
|
||||
private var disposeBag = Set<AnyCancellable>()
|
||||
private var tableViewObservation: NSKeyValueObservation?
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var viewModel: MastodonPickServerViewModel!
|
||||
|
||||
private var isAuthenticating = CurrentValueSubject<Bool, Never>(false)
|
||||
|
||||
private var expandServerDomainSet = Set<String>()
|
||||
|
||||
enum Section: CaseIterable {
|
||||
case title
|
||||
case categories
|
||||
case search
|
||||
case serverList
|
||||
}
|
||||
|
||||
let emptyStateView = PickServerEmptyStateView()
|
||||
let tableViewTopPaddingView = UIView() // fix empty state view background display when tableView bounce scrolling
|
||||
var tableViewTopPaddingViewHeightLayoutConstraint: NSLayoutConstraint!
|
||||
|
||||
let tableView: UITableView = {
|
||||
let tableView = ControlContainableTableView()
|
||||
tableView.register(PickServerTitleCell.self, forCellReuseIdentifier: String(describing: PickServerTitleCell.self))
|
||||
|
@ -53,6 +48,7 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
|
|||
}()
|
||||
|
||||
deinit {
|
||||
tableViewObservation = nil
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
|
@ -60,10 +56,6 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency
|
|||
|
||||
extension MastodonPickServerViewController {
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
return .darkContent
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
|
@ -78,12 +70,44 @@ extension MastodonPickServerViewController {
|
|||
view.layoutMarginsGuide.bottomAnchor.constraint(equalTo: nextStepButton.bottomAnchor, constant: WelcomeViewController.viewBottomPaddingHeight),
|
||||
])
|
||||
|
||||
emptyStateView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(emptyStateView)
|
||||
NSLayoutConstraint.activate([
|
||||
emptyStateView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
emptyStateView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
|
||||
emptyStateView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor),
|
||||
nextStepButton.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 21),
|
||||
])
|
||||
|
||||
// fix AutoLayout warning when observe before view appear
|
||||
viewModel.viewWillAppear
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.tableViewObservation = self.tableView.observe(\.contentSize, options: [.initial, .new]) { [weak self] tableView, _ in
|
||||
guard let self = self else { return }
|
||||
self.updateEmptyStateViewLayout()
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
tableViewTopPaddingView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(tableViewTopPaddingView)
|
||||
tableViewTopPaddingViewHeightLayoutConstraint = tableViewTopPaddingView.heightAnchor.constraint(equalToConstant: 0.0).priority(.defaultHigh)
|
||||
NSLayoutConstraint.activate([
|
||||
tableViewTopPaddingView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
|
||||
tableViewTopPaddingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
tableViewTopPaddingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
tableViewTopPaddingViewHeightLayoutConstraint,
|
||||
])
|
||||
tableViewTopPaddingView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||
|
||||
view.addSubview(tableView)
|
||||
NSLayoutConstraint.activate([
|
||||
tableView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
nextStepButton.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 7)
|
||||
nextStepButton.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 7),
|
||||
])
|
||||
|
||||
switch viewModel.mode {
|
||||
|
@ -95,31 +119,17 @@ extension MastodonPickServerViewController {
|
|||
nextStepButton.addTarget(self, action: #selector(nextStepButtonDidClicked(_:)), for: .touchUpInside)
|
||||
|
||||
tableView.delegate = self
|
||||
tableView.dataSource = self
|
||||
|
||||
viewModel
|
||||
.searchedServers
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { _ in
|
||||
|
||||
} receiveValue: { [weak self] servers in
|
||||
self?.tableView.beginUpdates()
|
||||
self?.tableView.reloadSections(IndexSet(integer: 3), with: .automatic)
|
||||
self?.tableView.endUpdates()
|
||||
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.setupDiffableDataSource(
|
||||
for: tableView,
|
||||
dependency: self,
|
||||
pickServerCategoriesCellDelegate: self,
|
||||
pickServerSearchCellDelegate: self,
|
||||
pickServerCellDelegate: self
|
||||
)
|
||||
|
||||
viewModel
|
||||
.selectedServer
|
||||
.map {
|
||||
$0 != nil
|
||||
}
|
||||
.map { $0 != nil }
|
||||
.assign(to: \.isEnabled, on: nextStepButton)
|
||||
.store(in: &disposeBag)
|
||||
|
||||
|
@ -158,7 +168,7 @@ extension MastodonPickServerViewController {
|
|||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
isAuthenticating
|
||||
viewModel.isAuthenticating
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isAuthenticating in
|
||||
guard let self = self else { return }
|
||||
|
@ -166,9 +176,42 @@ extension MastodonPickServerViewController {
|
|||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
viewModel.fetchAllServers()
|
||||
viewModel.emptyStateViewState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] state in
|
||||
guard let self = self else { return }
|
||||
switch state {
|
||||
case .none:
|
||||
self.emptyStateView.isHidden = true
|
||||
case .loading:
|
||||
self.emptyStateView.isHidden = false
|
||||
self.emptyStateView.networkIndicatorImageView.isHidden = true
|
||||
self.emptyStateView.activityIndicatorView.startAnimating()
|
||||
self.emptyStateView.infoLabel.isHidden = false
|
||||
self.emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.findingServers
|
||||
self.emptyStateView.infoLabel.textAlignment = self.traitCollection.layoutDirection == .rightToLeft ? .right : .left
|
||||
case .badNetwork:
|
||||
self.emptyStateView.isHidden = false
|
||||
self.emptyStateView.networkIndicatorImageView.isHidden = false
|
||||
self.emptyStateView.activityIndicatorView.stopAnimating()
|
||||
self.emptyStateView.infoLabel.isHidden = false
|
||||
self.emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.badNetwork
|
||||
self.emptyStateView.infoLabel.textAlignment = .center
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
viewModel.viewWillAppear.send()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
extension MastodonPickServerViewController {
|
||||
|
||||
@objc
|
||||
private func nextStepButtonDidClicked(_ sender: UIButton) {
|
||||
switch viewModel.mode {
|
||||
|
@ -181,7 +224,7 @@ extension MastodonPickServerViewController {
|
|||
|
||||
private func doSignIn() {
|
||||
guard let server = viewModel.selectedServer.value else { return }
|
||||
isAuthenticating.send(true)
|
||||
viewModel.isAuthenticating.send(true)
|
||||
context.apiService.createApplication(domain: server.domain)
|
||||
.tryMap { response -> MastodonPickServerViewModel.AuthenticateInfo in
|
||||
let application = response.value
|
||||
|
@ -193,7 +236,7 @@ extension MastodonPickServerViewController {
|
|||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] completion in
|
||||
guard let self = self else { return }
|
||||
self.isAuthenticating.send(false)
|
||||
self.viewModel.isAuthenticating.send(false)
|
||||
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
|
@ -221,7 +264,7 @@ extension MastodonPickServerViewController {
|
|||
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 }
|
||||
isAuthenticating.send(true)
|
||||
viewModel.isAuthenticating.send(true)
|
||||
|
||||
context.apiService.instance(domain: server.domain)
|
||||
.compactMap { [weak self] response -> AnyPublisher<MastodonPickServerViewModel.SignUpResponseFirst, Error>? in
|
||||
|
@ -257,7 +300,7 @@ extension MastodonPickServerViewController {
|
|||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] completion in
|
||||
guard let self = self else { return }
|
||||
self.isAuthenticating.send(false)
|
||||
self.viewModel.isAuthenticating.send(false)
|
||||
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
|
@ -291,141 +334,136 @@ extension MastodonPickServerViewController {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
extension MastodonPickServerViewController: UITableViewDelegate {
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard scrollView === tableView else { return }
|
||||
let offsetY = scrollView.contentOffset.y + scrollView.safeAreaInsets.top
|
||||
if offsetY < 0 {
|
||||
tableViewTopPaddingViewHeightLayoutConstraint.constant = abs(offsetY)
|
||||
} else {
|
||||
tableViewTopPaddingViewHeightLayoutConstraint.constant = 0
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
let headerView = UIView()
|
||||
headerView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||
return headerView
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||
let category = Section.allCases[section]
|
||||
switch category {
|
||||
case .title:
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return 0 }
|
||||
let sections = diffableDataSource.snapshot().sectionIdentifiers
|
||||
let section = sections[section]
|
||||
switch section {
|
||||
case .header:
|
||||
return 20
|
||||
case .categories:
|
||||
case .category:
|
||||
// Since category view has a blur shadow effect, its height need to be large than the actual height,
|
||||
// Thus we reduce the section header's height by 10, and make the category cell height 60+20(10 inset for top and bottom)
|
||||
return 10
|
||||
case .search:
|
||||
// Same reason as above
|
||||
return 10
|
||||
case .serverList:
|
||||
case .servers:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil }
|
||||
guard case let .server(server) = item else { return nil }
|
||||
|
||||
if tableView.indexPathForSelectedRow == indexPath {
|
||||
tableView.deselectRow(at: indexPath, animated: false)
|
||||
viewModel.selectedServer.send(nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
return indexPath
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
guard case let .server(server, _) = item else { return }
|
||||
tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
|
||||
viewModel.selectedServer.send(viewModel.searchedServers.value[indexPath.row])
|
||||
viewModel.selectedServer.send(server)
|
||||
}
|
||||
|
||||
|
||||
func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: false)
|
||||
viewModel.selectedServer.send(nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension MastodonPickServerViewController: UITableViewDataSource {
|
||||
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
return UIView()
|
||||
}
|
||||
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return Self.Section.allCases.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
let section = Self.Section.allCases[section]
|
||||
switch section {
|
||||
case .title,
|
||||
.categories,
|
||||
.search:
|
||||
return 1
|
||||
case .serverList:
|
||||
return viewModel.searchedServers.value.count
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
|
||||
let section = Self.Section.allCases[indexPath.section]
|
||||
switch section {
|
||||
case .title:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell
|
||||
return cell
|
||||
case .categories:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCategoriesCell.self), for: indexPath) as! PickServerCategoriesCell
|
||||
cell.dataSource = self
|
||||
cell.delegate = self
|
||||
return cell
|
||||
switch item {
|
||||
case .categoryPicker:
|
||||
guard let cell = cell as? PickServerCategoriesCell else { return }
|
||||
guard let diffableDataSource = cell.diffableDataSource else { return }
|
||||
let snapshot = diffableDataSource.snapshot()
|
||||
|
||||
let item = viewModel.selectCategoryItem.value
|
||||
guard let section = snapshot.indexOfSection(.main),
|
||||
let row = snapshot.indexOfItem(item) else { return }
|
||||
cell.collectionView.selectItem(at: IndexPath(item: row, section: section), animated: false, scrollPosition: .centeredHorizontally)
|
||||
case .search:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerSearchCell.self), for: indexPath) as! PickServerSearchCell
|
||||
cell.delegate = self
|
||||
return cell
|
||||
case .serverList:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCell.self), for: indexPath) as! PickServerCell
|
||||
let server = viewModel.searchedServers.value[indexPath.row]
|
||||
cell.server = server
|
||||
if expandServerDomainSet.contains(server.domain) {
|
||||
cell.mode = .expand
|
||||
} else {
|
||||
cell.mode = .collapse
|
||||
}
|
||||
if server == viewModel.selectedServer.value {
|
||||
tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
|
||||
} else {
|
||||
tableView.deselectRow(at: indexPath, animated: false)
|
||||
}
|
||||
|
||||
cell.delegate = self
|
||||
return cell
|
||||
guard let cell = cell as? PickServerSearchCell else { return }
|
||||
cell.searchTextField.text = viewModel.searchText.value
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonPickServerViewController {
|
||||
private func updateEmptyStateViewLayout() {
|
||||
guard let diffableDataSource = self.viewModel.diffableDataSource else { return }
|
||||
guard let indexPath = diffableDataSource.indexPath(for: .search) else { return }
|
||||
let rectInTableView = tableView.rectForRow(at: indexPath)
|
||||
|
||||
emptyStateView.topPaddingViewTopLayoutConstraint.constant = rectInTableView.maxY
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PickServerCategoriesCellDelegate
|
||||
extension MastodonPickServerViewController: PickServerCategoriesCellDelegate {
|
||||
func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard let diffableDataSource = cell.diffableDataSource else { return }
|
||||
let item = diffableDataSource.itemIdentifier(for: indexPath)
|
||||
viewModel.selectCategoryItem.value = item ?? .all
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PickServerSearchCellDelegate
|
||||
extension MastodonPickServerViewController: PickServerSearchCellDelegate {
|
||||
func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?) {
|
||||
viewModel.searchText.send(searchText ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PickServerCellDelegate
|
||||
extension MastodonPickServerViewController: PickServerCellDelegate {
|
||||
func pickServerCell(modeChange server: Mastodon.Entity.Server, newMode: PickServerCell.Mode, updates: (() -> Void)) {
|
||||
if newMode == .collapse {
|
||||
expandServerDomainSet.remove(server.domain)
|
||||
} else {
|
||||
expandServerDomainSet.insert(server.domain)
|
||||
}
|
||||
func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton) {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
guard let indexPath = tableView.indexPath(for: cell) else { return }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
guard case let .server(_, attribute) = item else { return }
|
||||
|
||||
attribute.isExpand.toggle()
|
||||
tableView.beginUpdates()
|
||||
updates()
|
||||
cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse)
|
||||
tableView.endUpdates()
|
||||
|
||||
if newMode == .expand, let modeChangeIndex = self.viewModel.searchedServers.value.firstIndex(where: { $0 == server }), self.tableView.indexPathsForVisibleRows?.last?.row == modeChangeIndex {
|
||||
self.tableView.scrollToRow(at: IndexPath(row: modeChangeIndex, section: 3), at: .bottom, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MastodonPickServerViewController: PickServerSearchCellDelegate {
|
||||
func pickServerSearchCell(didChange searchText: String?) {
|
||||
viewModel.searchText.send(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
extension MastodonPickServerViewController: PickServerCategoriesDataSource, PickServerCategoriesDelegate {
|
||||
func numberOfCategories() -> Int {
|
||||
return viewModel.categories.count
|
||||
}
|
||||
|
||||
func category(at index: Int) -> MastodonPickServerViewModel.Category {
|
||||
return viewModel.categories[index]
|
||||
}
|
||||
|
||||
func selectedIndex() -> Int {
|
||||
return viewModel.selectCategoryIndex.value
|
||||
}
|
||||
|
||||
func pickServerCategoriesCell(didSelect index: Int) {
|
||||
return viewModel.selectCategoryIndex.send(index)
|
||||
// expand attribute change do not needs apply snapshot to diffable data source
|
||||
// but should I block the viewModel data binding during tableView.beginUpdates/endUpdates?
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
//
|
||||
// MastodonPickServerViewController+Diffable.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021/3/5.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension MastodonPickServerViewModel {
|
||||
|
||||
func setupDiffableDataSource(
|
||||
for tableView: UITableView,
|
||||
dependency: NeedsDependency,
|
||||
pickServerCategoriesCellDelegate: PickServerCategoriesCellDelegate,
|
||||
pickServerSearchCellDelegate: PickServerSearchCellDelegate,
|
||||
pickServerCellDelegate: PickServerCellDelegate
|
||||
) {
|
||||
diffableDataSource = PickServerSection.tableViewDiffableDataSource(
|
||||
for: tableView,
|
||||
dependency: dependency,
|
||||
pickServerCategoriesCellDelegate: pickServerCategoriesCellDelegate,
|
||||
pickServerSearchCellDelegate: pickServerSearchCellDelegate,
|
||||
pickServerCellDelegate: pickServerCellDelegate
|
||||
)
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<PickServerSection, PickServerItem>()
|
||||
snapshot.appendSections([.header, .category, .search, .servers])
|
||||
snapshot.appendItems([.header], toSection: .header)
|
||||
snapshot.appendItems([.categoryPicker(items: categoryPickerItems)], toSection: .category)
|
||||
snapshot.appendItems([.search], toSection: .search)
|
||||
diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
|
||||
loadIndexedServerStateMachine.enter(LoadIndexedServerState.Loading.self)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
//
|
||||
// MastodonPickServerViewModel+LoadIndexedServerState.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021/3/5.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import GameplayKit
|
||||
import MastodonSDK
|
||||
|
||||
extension MastodonPickServerViewModel {
|
||||
class LoadIndexedServerState: GKState {
|
||||
weak var viewModel: MastodonPickServerViewModel?
|
||||
|
||||
init(viewModel: MastodonPickServerViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MastodonPickServerViewModel.LoadIndexedServerState {
|
||||
|
||||
class Initial: MastodonPickServerViewModel.LoadIndexedServerState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
return stateClass == Loading.self
|
||||
}
|
||||
}
|
||||
|
||||
class Loading: MastodonPickServerViewModel.LoadIndexedServerState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
return stateClass == Fail.self || stateClass == Idle.self
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
|
||||
guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return }
|
||||
viewModel.isLoadingIndexedServers.value = true
|
||||
viewModel.context.apiService.servers(language: nil, category: nil)
|
||||
.sink { completion in
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
// TODO: handle error
|
||||
stateMachine.enter(Fail.self)
|
||||
case .finished:
|
||||
break
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let _ = self else { return }
|
||||
stateMachine.enter(Idle.self)
|
||||
viewModel.indexedServers.value = response.value
|
||||
}
|
||||
.store(in: &viewModel.disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
class Fail: MastodonPickServerViewModel.LoadIndexedServerState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
return stateClass == Loading.self
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
|
||||
guard let stateMachine = self.stateMachine else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
guard let _ = self else { return }
|
||||
stateMachine.enter(Loading.self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Idle: MastodonPickServerViewModel.LoadIndexedServerState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
|
||||
guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return }
|
||||
viewModel.isLoadingIndexedServers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -5,80 +5,65 @@
|
|||
// Created by BradGao on 2021/2/23.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import OSLog
|
||||
import Combine
|
||||
import GameplayKit
|
||||
import MastodonSDK
|
||||
import CoreDataStack
|
||||
|
||||
class MastodonPickServerViewModel: NSObject {
|
||||
|
||||
enum PickServerMode {
|
||||
case signUp
|
||||
case signIn
|
||||
}
|
||||
|
||||
enum Category {
|
||||
// `all` means search for all categories
|
||||
case all
|
||||
// `some` means search for specific category
|
||||
case some(Mastodon.Entity.Category)
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all:
|
||||
return L10n.Scene.ServerPicker.Button.Category.all
|
||||
case .some(let masCategory):
|
||||
// TODO: Use emoji as placeholders
|
||||
switch masCategory.category {
|
||||
case .academia:
|
||||
return "📚"
|
||||
case .activism:
|
||||
return "✊"
|
||||
case .food:
|
||||
return "🍕"
|
||||
case .furry:
|
||||
return "🦁"
|
||||
case .games:
|
||||
return "🕹"
|
||||
case .general:
|
||||
return "GE"
|
||||
case .journalism:
|
||||
return "📰"
|
||||
case .lgbt:
|
||||
return "🏳️🌈"
|
||||
case .regional:
|
||||
return "📍"
|
||||
case .art:
|
||||
return "🎨"
|
||||
case .music:
|
||||
return "🎼"
|
||||
case .tech:
|
||||
return "📱"
|
||||
case ._other:
|
||||
return "❓"
|
||||
}
|
||||
}
|
||||
}
|
||||
enum EmptyStateViewState {
|
||||
case none
|
||||
case loading
|
||||
case badNetwork
|
||||
}
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let mode: PickServerMode
|
||||
let context: AppContext
|
||||
var categoryPickerItems: [CategoryPickerItem] = {
|
||||
var items: [CategoryPickerItem] = []
|
||||
items.append(.all)
|
||||
items.append(contentsOf: APIService.stubCategories().map { CategoryPickerItem.category(category: $0) })
|
||||
return items
|
||||
}()
|
||||
let selectCategoryItem = CurrentValueSubject<CategoryPickerItem, Never>(.all)
|
||||
let searchText = CurrentValueSubject<String, Never>("")
|
||||
let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
|
||||
let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
|
||||
let viewWillAppear = PassthroughSubject<Void, Never>()
|
||||
|
||||
var categories = [Category]()
|
||||
let selectCategoryIndex = CurrentValueSubject<Int, Never>(0)
|
||||
|
||||
let searchText = CurrentValueSubject<String?, Never>(nil)
|
||||
|
||||
let allServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
|
||||
let searchedServers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([])
|
||||
|
||||
// output
|
||||
var diffableDataSource: UITableViewDiffableDataSource<PickServerSection, PickServerItem>?
|
||||
private(set) lazy var loadIndexedServerStateMachine: GKStateMachine = {
|
||||
// exclude timeline middle fetcher state
|
||||
let stateMachine = GKStateMachine(states: [
|
||||
LoadIndexedServerState.Initial(viewModel: self),
|
||||
LoadIndexedServerState.Loading(viewModel: self),
|
||||
LoadIndexedServerState.Fail(viewModel: self),
|
||||
LoadIndexedServerState.Idle(viewModel: self),
|
||||
])
|
||||
stateMachine.enter(LoadIndexedServerState.Initial.self)
|
||||
return stateMachine
|
||||
}()
|
||||
let filteredIndexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
|
||||
let servers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([])
|
||||
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?
|
||||
let isAuthenticating = CurrentValueSubject<Bool, Never>(false)
|
||||
|
||||
let isLoadingIndexedServers = CurrentValueSubject<Bool, Never>(false)
|
||||
let emptyStateViewState = CurrentValueSubject<EmptyStateViewState, Never>(.none)
|
||||
|
||||
var mastodonPinBasedAuthenticationViewController: UIViewController?
|
||||
|
||||
|
@ -91,84 +76,135 @@ class MastodonPickServerViewModel: NSObject {
|
|||
}
|
||||
|
||||
private func configure() {
|
||||
let masCategories = context.apiService.stubCategories()
|
||||
categories.append(.all)
|
||||
categories.append(contentsOf: masCategories.map { Category.some($0) })
|
||||
|
||||
Publishers.CombineLatest3(
|
||||
selectCategoryIndex,
|
||||
searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(),
|
||||
allServers
|
||||
Publishers.CombineLatest(
|
||||
filteredIndexedServers.eraseToAnyPublisher(),
|
||||
unindexedServers.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() }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveValue: { [weak self] indexedServers, unindexedServers in
|
||||
guard let self = self else { return }
|
||||
guard let diffableDataSource = self.diffableDataSource else { return }
|
||||
|
||||
// 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(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, let _ = URL(string: "https://\(toSearchText)") {
|
||||
return self.context.apiService.instance(domain: toSearchText)
|
||||
.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(Result.success(searchedServersFromAPI)).eraseToAnyPublisher()
|
||||
}
|
||||
.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([])
|
||||
let oldSnapshot = diffableDataSource.snapshot()
|
||||
var oldSnapshotServerItemAttributeDict: [String : PickServerItem.ServerItemAttribute] = [:]
|
||||
for item in oldSnapshot.itemIdentifiers {
|
||||
guard case let .server(server, attribute) = item else { continue }
|
||||
oldSnapshotServerItemAttributeDict[server.domain] = attribute
|
||||
}
|
||||
|
||||
}
|
||||
var snapshot = NSDiffableDataSourceSnapshot<PickServerSection, PickServerItem>()
|
||||
snapshot.appendSections([.header, .category, .search, .servers])
|
||||
snapshot.appendItems([.header], toSection: .header)
|
||||
snapshot.appendItems([.categoryPicker(items: self.categoryPickerItems)], toSection: .category)
|
||||
snapshot.appendItems([.search], toSection: .search)
|
||||
// TODO: handle filter
|
||||
var serverItems: [PickServerItem] = []
|
||||
for server in indexedServers {
|
||||
let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false)
|
||||
attribute.isLast = false
|
||||
let item = PickServerItem.server(server: server, attribute: attribute)
|
||||
guard !serverItems.contains(item) else { continue }
|
||||
serverItems.append(item)
|
||||
}
|
||||
for server in unindexedServers {
|
||||
let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false)
|
||||
attribute.isLast = false
|
||||
let item = PickServerItem.server(server: server, attribute: attribute)
|
||||
guard !serverItems.contains(item) else { continue }
|
||||
serverItems.append(item)
|
||||
}
|
||||
if case let .server(_, attribute) = serverItems.last {
|
||||
attribute.isLast = true
|
||||
}
|
||||
snapshot.appendItems(serverItems, toSection: .servers)
|
||||
|
||||
diffableDataSource.defaultRowAnimation = .fade
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: true, completion: nil)
|
||||
})
|
||||
.store(in: &disposeBag)
|
||||
|
||||
|
||||
}
|
||||
|
||||
func fetchAllServers() {
|
||||
context.apiService.servers(language: nil, category: nil)
|
||||
.sink { completion in
|
||||
// TODO: Add a reload button when fails to fetch servers initially
|
||||
} receiveValue: { [weak self] result in
|
||||
self?.allServers.send(result.value)
|
||||
isLoadingIndexedServers
|
||||
.map { isLoadingIndexedServers -> EmptyStateViewState in
|
||||
if isLoadingIndexedServers {
|
||||
return .loading
|
||||
} else {
|
||||
return .none
|
||||
}
|
||||
}
|
||||
.assign(to: \.value, on: emptyStateViewState)
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest3(
|
||||
indexedServers.eraseToAnyPublisher(),
|
||||
selectCategoryItem.eraseToAnyPublisher(),
|
||||
searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates()
|
||||
)
|
||||
.map { indexedServers, selectCategoryItem, searchText -> [Mastodon.Entity.Server] in
|
||||
// Filter the indexed servers from joinmastodon.org
|
||||
switch selectCategoryItem {
|
||||
case .all:
|
||||
return MastodonPickServerViewModel.filterServers(servers: indexedServers, category: nil, searchText: searchText)
|
||||
case .category(let category):
|
||||
return MastodonPickServerViewModel.filterServers(servers: indexedServers, category: category.category.rawValue, searchText: searchText)
|
||||
}
|
||||
}
|
||||
.assign(to: \.value, on: filteredIndexedServers)
|
||||
.store(in: &disposeBag)
|
||||
|
||||
searchText
|
||||
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||
.removeDuplicates()
|
||||
.compactMap { [weak self] searchText -> AnyPublisher<Result<Mastodon.Response.Content<[Mastodon.Entity.Server]>, Error>, Never>? in
|
||||
// Check if searchText is a valid mastodon server domain
|
||||
guard let self = self else { return nil }
|
||||
guard let domain = AuthenticationViewModel.parseDomain(from: searchText) else {
|
||||
return Just(Result.failure(APIService.APIError.implicit(.badRequest))).eraseToAnyPublisher()
|
||||
}
|
||||
return self.context.apiService.instance(domain: domain)
|
||||
.map { response -> Result<Mastodon.Response.Content<[Mastodon.Entity.Server]>, Error>in
|
||||
let newResponse = response.map { [Mastodon.Entity.Server(instance: $0)] }
|
||||
return Result.success(newResponse)
|
||||
}
|
||||
.catch { error in
|
||||
return Just(Result.failure(error))
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.switchToLatest()
|
||||
.sink(receiveValue: { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .success(let response):
|
||||
self.unindexedServers.send(response.value)
|
||||
case .failure(let error):
|
||||
// TODO: What should be presented when user inputs invalid search text?
|
||||
self.unindexedServers.send([])
|
||||
}
|
||||
})
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
private func searchServersFromAPI(category: Category, searchText: String?, allServers: [Mastodon.Entity.Server]) -> [Mastodon.Entity.Server] {
|
||||
return allServers
|
||||
|
||||
}
|
||||
|
||||
extension MastodonPickServerViewModel {
|
||||
private static func filterServers(servers: [Mastodon.Entity.Server], category: String?, searchText: String) -> [Mastodon.Entity.Server] {
|
||||
return servers
|
||||
// 1. Filter the category
|
||||
.filter {
|
||||
switch category {
|
||||
case .all:
|
||||
return true
|
||||
case .some(let masCategory):
|
||||
return $0.category.caseInsensitiveCompare(masCategory.category.rawValue) == .orderedSame
|
||||
}
|
||||
guard let category = category else { return true }
|
||||
return $0.category.caseInsensitiveCompare(category) == .orderedSame
|
||||
}
|
||||
// 2. Filter the searchText
|
||||
.filter {
|
||||
if let searchText = searchText, !searchText.isEmpty {
|
||||
return $0.domain.lowercased().contains(searchText.lowercased())
|
||||
} else {
|
||||
let searchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !searchText.isEmpty else {
|
||||
return true
|
||||
}
|
||||
return $0.domain.lowercased().contains(searchText.lowercased())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - SignIn methods & structs
|
||||
extension MastodonPickServerViewModel {
|
||||
|
|
|
@ -5,24 +5,20 @@
|
|||
// Created by BradGao on 2021/2/23.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import MastodonSDK
|
||||
|
||||
protocol PickServerCategoriesDataSource: class {
|
||||
func numberOfCategories() -> Int
|
||||
func category(at index: Int) -> MastodonPickServerViewModel.Category
|
||||
func selectedIndex() -> Int
|
||||
}
|
||||
|
||||
protocol PickServerCategoriesDelegate: class {
|
||||
func pickServerCategoriesCell(didSelect index: Int)
|
||||
protocol PickServerCategoriesCellDelegate: class {
|
||||
func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
||||
}
|
||||
|
||||
final class PickServerCategoriesCell: UITableViewCell {
|
||||
|
||||
weak var dataSource: PickServerCategoriesDataSource!
|
||||
weak var delegate: PickServerCategoriesDelegate!
|
||||
weak var delegate: PickServerCategoriesCellDelegate?
|
||||
|
||||
var diffableDataSource: UICollectionViewDiffableDataSource<CategoryPickerSection, CategoryPickerItem>?
|
||||
|
||||
let metricView = UIView()
|
||||
|
||||
let collectionView: UICollectionView = {
|
||||
|
@ -38,6 +34,12 @@ final class PickServerCategoriesCell: UITableViewCell {
|
|||
return view
|
||||
}()
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
delegate = nil
|
||||
}
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
_init()
|
||||
|
@ -52,8 +54,8 @@ final class PickServerCategoriesCell: UITableViewCell {
|
|||
extension PickServerCategoriesCell {
|
||||
|
||||
private func _init() {
|
||||
self.selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||
|
||||
metricView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(metricView)
|
||||
|
@ -75,7 +77,6 @@ extension PickServerCategoriesCell {
|
|||
])
|
||||
|
||||
collectionView.delegate = self
|
||||
collectionView.dataSource = self
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
|
@ -86,45 +87,26 @@ extension PickServerCategoriesCell {
|
|||
|
||||
}
|
||||
|
||||
// MARK: - UICollectionViewDelegateFlowLayout
|
||||
extension PickServerCategoriesCell: UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally)
|
||||
delegate.pickServerCategoriesCell(didSelect: indexPath.row)
|
||||
}
|
||||
|
||||
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?.pickServerCategoriesCell(self, collectionView: collectionView, didSelectItemAt: indexPath)
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
|
||||
layoutIfNeeded()
|
||||
return UIEdgeInsets(top: 0, left: metricView.frame.minX - collectionView.frame.minX, bottom: 0, right: collectionView.frame.maxX - metricView.frame.maxX)
|
||||
}
|
||||
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
|
||||
return 16
|
||||
}
|
||||
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||||
return CGSize(width: 60, height: 80)
|
||||
}
|
||||
}
|
||||
|
||||
extension PickServerCategoriesCell: UICollectionViewDataSource {
|
||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
return dataSource.numberOfCategories()
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
let category = dataSource.category(at: indexPath.row)
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell
|
||||
cell.category = category
|
||||
|
||||
// Select the default category by default
|
||||
if indexPath.row == dataSource.selectedIndex() {
|
||||
// Use `[]` as the scrollPosition to avoid contentOffset change
|
||||
collectionView.selectItem(at: indexPath, animated: false, scrollPosition: [])
|
||||
cell.isSelected = true
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -5,25 +5,26 @@
|
|||
// Created by BradGao on 2021/2/24.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import MastodonSDK
|
||||
import Kingfisher
|
||||
import AlamofireImage
|
||||
import Kanna
|
||||
|
||||
protocol PickServerCellDelegate: class {
|
||||
func pickServerCell(modeChange server: Mastodon.Entity.Server, newMode: PickServerCell.Mode, updates: (() -> Void))
|
||||
func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton)
|
||||
}
|
||||
|
||||
class PickServerCell: UITableViewCell {
|
||||
|
||||
weak var delegate: PickServerCellDelegate?
|
||||
|
||||
enum Mode {
|
||||
case collapse
|
||||
case expand
|
||||
}
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
private var containerView: UIView = {
|
||||
let expandMode = CurrentValueSubject<ExpandMode, Never>(.collapse)
|
||||
|
||||
let containerView: UIView = {
|
||||
let view = UIView()
|
||||
view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16)
|
||||
view.backgroundColor = Asset.Colors.lightWhite.color
|
||||
|
@ -31,7 +32,7 @@ class PickServerCell: UITableViewCell {
|
|||
return view
|
||||
}()
|
||||
|
||||
private var domainLabel: UILabel = {
|
||||
let domainLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = .preferredFont(forTextStyle: .headline)
|
||||
label.textColor = Asset.Colors.lightDarkGray.color
|
||||
|
@ -40,7 +41,7 @@ class PickServerCell: UITableViewCell {
|
|||
return label
|
||||
}()
|
||||
|
||||
private var checkbox: UIImageView = {
|
||||
let checkbox: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body)
|
||||
imageView.tintColor = Asset.Colors.lightSecondaryText.color
|
||||
|
@ -49,7 +50,7 @@ class PickServerCell: UITableViewCell {
|
|||
return imageView
|
||||
}()
|
||||
|
||||
private var descriptionLabel: UILabel = {
|
||||
let descriptionLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = .preferredFont(forTextStyle: .subheadline)
|
||||
label.numberOfLines = 0
|
||||
|
@ -59,7 +60,9 @@ class PickServerCell: UITableViewCell {
|
|||
return label
|
||||
}()
|
||||
|
||||
private var thumbImageView: UIImageView = {
|
||||
let thumbnailActivityIdicator = UIActivityIndicatorView(style: .medium)
|
||||
|
||||
let thumbnailImageView: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
imageView.clipsToBounds = true
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
|
@ -67,7 +70,7 @@ class PickServerCell: UITableViewCell {
|
|||
return imageView
|
||||
}()
|
||||
|
||||
private var infoStackView: UIStackView = {
|
||||
let infoStackView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
stackView.alignment = .fill
|
||||
|
@ -76,14 +79,14 @@ class PickServerCell: UITableViewCell {
|
|||
return stackView
|
||||
}()
|
||||
|
||||
private var expandBox: UIView = {
|
||||
let expandBox: UIView = {
|
||||
let view = UIView()
|
||||
view.backgroundColor = .clear
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
return view
|
||||
}()
|
||||
|
||||
private var expandButton: UIButton = {
|
||||
let expandButton: UIButton = {
|
||||
let button = UIButton(type: .custom)
|
||||
button.setTitle(L10n.Scene.ServerPicker.Button.seemore, for: .normal)
|
||||
button.setTitle(L10n.Scene.ServerPicker.Button.seeless, for: .selected)
|
||||
|
@ -93,14 +96,14 @@ class PickServerCell: UITableViewCell {
|
|||
return button
|
||||
}()
|
||||
|
||||
private var seperator: UIView = {
|
||||
let seperator: UIView = {
|
||||
let view = UIView()
|
||||
view.backgroundColor = Asset.Colors.lightBackground.color
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
return view
|
||||
}()
|
||||
|
||||
private var langValueLabel: UILabel = {
|
||||
let langValueLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textColor = Asset.Colors.lightDarkGray.color
|
||||
label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold))
|
||||
|
@ -110,7 +113,7 @@ class PickServerCell: UITableViewCell {
|
|||
return label
|
||||
}()
|
||||
|
||||
private var usersValueLabel: UILabel = {
|
||||
let usersValueLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textColor = Asset.Colors.lightDarkGray.color
|
||||
label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold))
|
||||
|
@ -120,7 +123,7 @@ class PickServerCell: UITableViewCell {
|
|||
return label
|
||||
}()
|
||||
|
||||
private var categoryValueLabel: UILabel = {
|
||||
let categoryValueLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textColor = Asset.Colors.lightDarkGray.color
|
||||
label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold))
|
||||
|
@ -130,7 +133,7 @@ class PickServerCell: UITableViewCell {
|
|||
return label
|
||||
}()
|
||||
|
||||
private var langTitleLabel: UILabel = {
|
||||
let langTitleLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textColor = Asset.Colors.lightDarkGray.color
|
||||
label.font = .preferredFont(forTextStyle: .caption2)
|
||||
|
@ -141,7 +144,7 @@ class PickServerCell: UITableViewCell {
|
|||
return label
|
||||
}()
|
||||
|
||||
private var usersTitleLabel: UILabel = {
|
||||
let usersTitleLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textColor = Asset.Colors.lightDarkGray.color
|
||||
label.font = .preferredFont(forTextStyle: .caption2)
|
||||
|
@ -152,7 +155,7 @@ class PickServerCell: UITableViewCell {
|
|||
return label
|
||||
}()
|
||||
|
||||
private var categoryTitleLabel: UILabel = {
|
||||
let categoryTitleLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textColor = Asset.Colors.lightDarkGray.color
|
||||
label.font = .preferredFont(forTextStyle: .caption2)
|
||||
|
@ -166,16 +169,13 @@ class PickServerCell: UITableViewCell {
|
|||
private var collapseConstraints: [NSLayoutConstraint] = []
|
||||
private var expandConstraints: [NSLayoutConstraint] = []
|
||||
|
||||
var mode: PickServerCell.Mode = .collapse {
|
||||
didSet {
|
||||
updateMode()
|
||||
}
|
||||
}
|
||||
|
||||
var server: Mastodon.Entity.Server? {
|
||||
didSet {
|
||||
updateServerInfo()
|
||||
}
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
thumbnailImageView.isHidden = false
|
||||
thumbnailImageView.af.cancelImageRequest()
|
||||
thumbnailActivityIdicator.stopAnimating()
|
||||
disposeBag.removeAll()
|
||||
}
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
|
@ -187,6 +187,7 @@ class PickServerCell: UITableViewCell {
|
|||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Methods to configure appearance
|
||||
|
@ -205,7 +206,7 @@ extension PickServerCell {
|
|||
|
||||
// Always add the expandbox which contains elements only visible in expand mode
|
||||
containerView.addSubview(expandBox)
|
||||
expandBox.addSubview(thumbImageView)
|
||||
expandBox.addSubview(thumbnailImageView)
|
||||
expandBox.addSubview(infoStackView)
|
||||
expandBox.isHidden = true
|
||||
|
||||
|
@ -216,7 +217,7 @@ extension PickServerCell {
|
|||
infoStackView.addArrangedSubview(verticalInfoStackViewUsers)
|
||||
infoStackView.addArrangedSubview(verticalInfoStackViewCategory)
|
||||
|
||||
let expandButtonTopConstraintInCollapse = expandButton.topAnchor.constraint(equalTo: descriptionLabel.lastBaselineAnchor, constant: 12).priority(.required)
|
||||
let expandButtonTopConstraintInCollapse = expandButton.topAnchor.constraint(equalTo: descriptionLabel.lastBaselineAnchor, constant: 12).priority(.required - 1)
|
||||
collapseConstraints.append(expandButtonTopConstraintInCollapse)
|
||||
|
||||
let expandButtonTopConstraintInExpand = expandButton.topAnchor.constraint(equalTo: expandBox.bottomAnchor, constant: 8).priority(.defaultHigh)
|
||||
|
@ -254,20 +255,29 @@ extension PickServerCell {
|
|||
expandBox.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: 8),
|
||||
expandBox.bottomAnchor.constraint(equalTo: infoStackView.bottomAnchor).priority(.defaultHigh),
|
||||
|
||||
thumbImageView.topAnchor.constraint(equalTo: expandBox.topAnchor),
|
||||
thumbImageView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor),
|
||||
expandBox.trailingAnchor.constraint(equalTo: thumbImageView.trailingAnchor),
|
||||
thumbImageView.heightAnchor.constraint(equalTo: thumbImageView.widthAnchor, multiplier: 151.0 / 303.0).priority(.defaultHigh),
|
||||
thumbnailImageView.topAnchor.constraint(equalTo: expandBox.topAnchor),
|
||||
thumbnailImageView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor),
|
||||
expandBox.trailingAnchor.constraint(equalTo: thumbnailImageView.trailingAnchor),
|
||||
thumbnailImageView.heightAnchor.constraint(equalTo: thumbnailImageView.widthAnchor, multiplier: 151.0 / 303.0).priority(.defaultHigh),
|
||||
|
||||
infoStackView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor),
|
||||
expandBox.trailingAnchor.constraint(equalTo: infoStackView.trailingAnchor),
|
||||
infoStackView.topAnchor.constraint(equalTo: thumbImageView.bottomAnchor, constant: 16),
|
||||
infoStackView.topAnchor.constraint(equalTo: thumbnailImageView.bottomAnchor, constant: 16),
|
||||
|
||||
expandButton.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor),
|
||||
containerView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: expandButton.trailingAnchor),
|
||||
containerView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: expandButton.bottomAnchor),
|
||||
])
|
||||
|
||||
thumbnailActivityIdicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
thumbnailImageView.addSubview(thumbnailActivityIdicator)
|
||||
NSLayoutConstraint.activate([
|
||||
thumbnailActivityIdicator.centerXAnchor.constraint(equalTo: thumbnailImageView.centerXAnchor),
|
||||
thumbnailActivityIdicator.centerYAnchor.constraint(equalTo: thumbnailImageView.centerYAnchor),
|
||||
])
|
||||
thumbnailActivityIdicator.hidesWhenStopped = true
|
||||
thumbnailActivityIdicator.stopAnimating()
|
||||
|
||||
NSLayoutConstraint.activate(collapseConstraints)
|
||||
|
||||
domainLabel.setContentHuggingPriority(.required - 1, for: .vertical)
|
||||
|
@ -275,7 +285,7 @@ extension PickServerCell {
|
|||
descriptionLabel.setContentHuggingPriority(.required - 2, for: .vertical)
|
||||
descriptionLabel.setContentCompressionResistancePriority(.required - 2, for: .vertical)
|
||||
|
||||
expandButton.addTarget(self, action: #selector(expandButtonDidClicked(_:)), for: .touchUpInside)
|
||||
expandButton.addTarget(self, action: #selector(expandButtonDidPressed(_:)), for: .touchUpInside)
|
||||
}
|
||||
|
||||
private func makeVerticalInfoStackView(arrangedView: UIView...) -> UIStackView {
|
||||
|
@ -288,8 +298,31 @@ extension PickServerCell {
|
|||
arrangedView.forEach { stackView.addArrangedSubview($0) }
|
||||
return stackView
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
private func updateMode() {
|
||||
@objc
|
||||
private func expandButtonDidPressed(_ sender: UIButton) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
delegate?.pickServerCell(self, expandButtonPressed: sender)
|
||||
}
|
||||
}
|
||||
|
||||
extension PickServerCell {
|
||||
|
||||
enum ExpandMode {
|
||||
case collapse
|
||||
case expand
|
||||
}
|
||||
|
||||
func updateExpandMode(mode: ExpandMode) {
|
||||
switch mode {
|
||||
case .collapse:
|
||||
expandBox.isHidden = true
|
||||
|
@ -302,57 +335,8 @@ extension PickServerCell {
|
|||
NSLayoutConstraint.activate(expandConstraints)
|
||||
NSLayoutConstraint.deactivate(collapseConstraints)
|
||||
}
|
||||
|
||||
expandMode.value = mode
|
||||
}
|
||||
|
||||
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) {
|
||||
let newMode: Mode = mode == .collapse ? .expand : .collapse
|
||||
delegate?.pickServerCell(modeChange: server!, newMode: newMode, updates: { [weak self] in
|
||||
self?.mode = newMode
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Methods to update data
|
||||
extension PickServerCell {
|
||||
private func updateServerInfo() {
|
||||
guard let serverInfo = server else { return }
|
||||
domainLabel.text = serverInfo.domain
|
||||
descriptionLabel.text = {
|
||||
guard let html = try? HTML(html: serverInfo.description, encoding: .utf8) else {
|
||||
return serverInfo.description
|
||||
}
|
||||
|
||||
return html.text ?? serverInfo.description
|
||||
}()
|
||||
let processor = RoundCornerImageProcessor(cornerRadius: 3)
|
||||
thumbImageView.kf.indicatorType = .activity
|
||||
thumbImageView.kf.setImage(with: URL(string: serverInfo.proxiedThumbnail ?? "")!, placeholder: UIImage.placeholder(color: Asset.Colors.lightBackground.color), options: [
|
||||
.processor(processor),
|
||||
.scaleFactor(UIScreen.main.scale),
|
||||
.transition(.fade(1))
|
||||
])
|
||||
langValueLabel.text = serverInfo.language.uppercased()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import UIKit
|
||||
|
||||
protocol PickServerSearchCellDelegate: class {
|
||||
func pickServerSearchCell(didChange searchText: String?)
|
||||
func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?)
|
||||
}
|
||||
|
||||
class PickServerSearchCell: UITableViewCell {
|
||||
|
@ -24,7 +24,7 @@ class PickServerSearchCell: UITableViewCell {
|
|||
.layerMaxXMinYCorner
|
||||
]
|
||||
view.layer.cornerCurve = .continuous
|
||||
view.layer.cornerRadius = 10
|
||||
view.layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius
|
||||
return view
|
||||
}()
|
||||
|
||||
|
@ -38,7 +38,7 @@ class PickServerSearchCell: UITableViewCell {
|
|||
return view
|
||||
}()
|
||||
|
||||
private var searchTextField: UITextField = {
|
||||
let searchTextField: UITextField = {
|
||||
let textField = UITextField()
|
||||
textField.translatesAutoresizingMaskIntoConstraints = false
|
||||
textField.font = .preferredFont(forTextStyle: .headline)
|
||||
|
@ -55,6 +55,12 @@ class PickServerSearchCell: UITableViewCell {
|
|||
return textField
|
||||
}()
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
delegate = nil
|
||||
}
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
_init()
|
||||
|
@ -68,8 +74,8 @@ class PickServerSearchCell: UITableViewCell {
|
|||
|
||||
extension PickServerSearchCell {
|
||||
private func _init() {
|
||||
self.selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||
|
||||
searchTextField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged)
|
||||
|
||||
|
@ -97,7 +103,7 @@ extension PickServerSearchCell {
|
|||
}
|
||||
|
||||
extension PickServerSearchCell {
|
||||
@objc func textFieldDidChange(_ textField: UITextField) {
|
||||
delegate?.pickServerSearchCell(didChange: textField.text)
|
||||
@objc private func textFieldDidChange(_ textField: UITextField) {
|
||||
delegate?.pickServerSearchCell(self, searchTextDidChange: textField.text)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,8 +34,8 @@ final class PickServerTitleCell: UITableViewCell {
|
|||
extension PickServerTitleCell {
|
||||
|
||||
private func _init() {
|
||||
self.selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||
|
||||
contentView.addSubview(titleLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
|
|
|
@ -9,16 +9,6 @@ import UIKit
|
|||
import MastodonSDK
|
||||
|
||||
class PickServerCategoryView: UIView {
|
||||
var category: MastodonPickServerViewModel.Category? {
|
||||
didSet {
|
||||
updateCategory()
|
||||
}
|
||||
}
|
||||
var selected: Bool = false {
|
||||
didSet {
|
||||
updateSelectStatus()
|
||||
}
|
||||
}
|
||||
|
||||
var bgShadowView: UIView = {
|
||||
let view = UIView()
|
||||
|
@ -53,47 +43,34 @@ class PickServerCategoryView: UIView {
|
|||
}
|
||||
|
||||
extension PickServerCategoryView {
|
||||
|
||||
private func configure() {
|
||||
addSubview(bgView)
|
||||
addSubview(titleLabel)
|
||||
|
||||
|
||||
bgView.backgroundColor = Asset.Colors.lightWhite.color
|
||||
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
bgView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
||||
bgView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||
bgView.topAnchor.constraint(equalTo: self.topAnchor),
|
||||
bgView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||
|
||||
|
||||
titleLabel.centerXAnchor.constraint(equalTo: self.centerXAnchor),
|
||||
titleLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
private func updateCategory() {
|
||||
guard let category = category else { return }
|
||||
titleLabel.text = category.title
|
||||
switch category {
|
||||
case .all:
|
||||
titleLabel.font = UIFont.systemFont(ofSize: 17)
|
||||
case .some:
|
||||
titleLabel.font = UIFont.systemFont(ofSize: 28)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSelectStatus() {
|
||||
if selected {
|
||||
bgView.backgroundColor = Asset.Colors.lightBrandBlue.color
|
||||
bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0)
|
||||
if case .all = category {
|
||||
titleLabel.textColor = Asset.Colors.lightWhite.color
|
||||
}
|
||||
} else {
|
||||
bgView.backgroundColor = Asset.Colors.lightWhite.color
|
||||
bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0)
|
||||
if case .all = category {
|
||||
titleLabel.textColor = Asset.Colors.lightBrandBlue.color
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG && canImport(SwiftUI)
|
||||
import SwiftUI
|
||||
|
||||
struct PickServerCategoryView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
UIViewPreview {
|
||||
PickServerCategoryView()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
@ -0,0 +1,141 @@
|
|||
//
|
||||
// PickServerEmptyStateView.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021/3/6.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class PickServerEmptyStateView: UIView {
|
||||
|
||||
var topPaddingViewTopLayoutConstraint: NSLayoutConstraint!
|
||||
|
||||
let networkIndicatorImageView: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
let configuration = UIImage.SymbolConfiguration(pointSize: 64, weight: .regular)
|
||||
imageView.image = UIImage(systemName: "wifi.exclamationmark", withConfiguration: configuration)
|
||||
imageView.tintColor = Asset.Colors.Label.secondary.color
|
||||
return imageView
|
||||
}()
|
||||
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
|
||||
let infoLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = .systemFont(ofSize: 17)
|
||||
label.textAlignment = .center
|
||||
label.textColor = Asset.Colors.Label.secondary.color
|
||||
label.text = "info"
|
||||
label.numberOfLines = 0
|
||||
return label
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension PickServerEmptyStateView {
|
||||
|
||||
private func _init() {
|
||||
backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
|
||||
layer.maskedCorners = [
|
||||
.layerMinXMaxYCorner,
|
||||
.layerMaxXMaxYCorner
|
||||
]
|
||||
layer.cornerCurve = .continuous
|
||||
layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius
|
||||
|
||||
let topPaddingView = UIView()
|
||||
topPaddingView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(topPaddingView)
|
||||
topPaddingViewTopLayoutConstraint = topPaddingView.topAnchor.constraint(equalTo: topAnchor, constant: 0)
|
||||
NSLayoutConstraint.activate([
|
||||
topPaddingViewTopLayoutConstraint,
|
||||
topPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
topPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
|
||||
let containerStackView = UIStackView()
|
||||
containerStackView.axis = .vertical
|
||||
containerStackView.alignment = .center
|
||||
containerStackView.distribution = .fill
|
||||
containerStackView.spacing = 16
|
||||
containerStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(containerStackView)
|
||||
NSLayoutConstraint.activate([
|
||||
containerStackView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor),
|
||||
containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
containerStackView.addArrangedSubview(networkIndicatorImageView)
|
||||
|
||||
let infoContainerView = UIView()
|
||||
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
|
||||
infoContainerView.addSubview(activityIndicatorView)
|
||||
NSLayoutConstraint.activate([
|
||||
activityIndicatorView.leadingAnchor.constraint(equalTo: infoContainerView.leadingAnchor),
|
||||
activityIndicatorView.centerYAnchor.constraint(equalTo: infoContainerView.centerYAnchor),
|
||||
activityIndicatorView.bottomAnchor.constraint(equalTo: infoContainerView.bottomAnchor),
|
||||
])
|
||||
infoLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
infoContainerView.addSubview(infoLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
infoLabel.leadingAnchor.constraint(equalTo: activityIndicatorView.trailingAnchor, constant: 4),
|
||||
infoLabel.centerYAnchor.constraint(equalTo: infoContainerView.centerYAnchor),
|
||||
infoLabel.trailingAnchor.constraint(equalTo: infoContainerView.trailingAnchor),
|
||||
])
|
||||
containerStackView.addArrangedSubview(infoContainerView)
|
||||
|
||||
let bottomPaddingView = UIView()
|
||||
bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(bottomPaddingView)
|
||||
NSLayoutConstraint.activate([
|
||||
bottomPaddingView.topAnchor.constraint(equalTo: containerStackView.bottomAnchor),
|
||||
bottomPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
bottomPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
bottomPaddingView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
bottomPaddingView.heightAnchor.constraint(equalTo: topPaddingView.heightAnchor, multiplier: 2.0),
|
||||
])
|
||||
|
||||
activityIndicatorView.hidesWhenStopped = true
|
||||
activityIndicatorView.startAnimating()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#if DEBUG && canImport(SwiftUI)
|
||||
import SwiftUI
|
||||
|
||||
struct PickServerEmptyStateView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
UIViewPreview(width: 375) {
|
||||
let emptyStateView = PickServerEmptyStateView()
|
||||
emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.badNetwork
|
||||
emptyStateView.infoLabel.textAlignment = .center
|
||||
emptyStateView.activityIndicatorView.stopAnimating()
|
||||
return emptyStateView
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 400))
|
||||
UIViewPreview(width: 375) {
|
||||
let emptyStateView = PickServerEmptyStateView()
|
||||
emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.findingServers
|
||||
emptyStateView.infoLabel.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft ? .right : .left
|
||||
emptyStateView.activityIndicatorView.startAnimating()
|
||||
return emptyStateView
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 400))
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
|
@ -39,8 +39,6 @@ final class MastodonPinBasedAuthenticationViewController: UIViewController, Need
|
|||
|
||||
}
|
||||
|
||||
|
||||
|
||||
extension MastodonPinBasedAuthenticationViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
|
|
|
@ -10,7 +10,8 @@ import Foundation
|
|||
import PhotosUI
|
||||
import UIKit
|
||||
|
||||
extension MastodonRegisterViewController: CropViewControllerDelegate, PHPickerViewControllerDelegate {
|
||||
// MARK: - PHPickerViewControllerDelegate
|
||||
extension MastodonRegisterViewController: PHPickerViewControllerDelegate {
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
guard let itemProvider = results.first?.itemProvider, itemProvider.canLoadObject(ofClass: UIImage.self) else {
|
||||
picker.dismiss(animated: true, completion: {})
|
||||
|
@ -44,13 +45,18 @@ extension MastodonRegisterViewController: CropViewControllerDelegate, PHPickerVi
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CropViewControllerDelegate
|
||||
extension MastodonRegisterViewController: CropViewControllerDelegate {
|
||||
public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) {
|
||||
self.viewModel.avatarImage.value = image
|
||||
self.photoButton.setImage(image, for: .normal)
|
||||
self.avatarButton.setImage(image, for: .normal)
|
||||
cropViewController.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension MastodonRegisterViewController {
|
||||
@objc func avatarButtonPressed(_ sender: UIButton) {
|
||||
self.present(imagePicker, animated: true, completion: nil)
|
||||
}
|
||||
|
|
|
@ -49,13 +49,13 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
|
|||
return label
|
||||
}()
|
||||
|
||||
let photoView: UIView = {
|
||||
let avatarView: UIView = {
|
||||
let view = UIView()
|
||||
view.backgroundColor = .clear
|
||||
return view
|
||||
}()
|
||||
|
||||
let photoButton: UIButton = {
|
||||
let avatarButton: UIButton = {
|
||||
let button = UIButton(type: .custom)
|
||||
let boldFont = UIFont.systemFont(ofSize: 42)
|
||||
let configuration = UIImage.SymbolConfiguration(font: boldFont)
|
||||
|
@ -67,11 +67,10 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
|
|||
button.layer.cornerRadius = 45
|
||||
button.clipsToBounds = true
|
||||
|
||||
button.addTarget(self, action: #selector(MastodonRegisterViewController.avatarButtonPressed(_:)), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
let plusIcon: UIImageView = {
|
||||
let plusIconImageView: UIImageView = {
|
||||
let icon = UIImageView()
|
||||
|
||||
let image = Asset.Circles.plusCircleFill.image.withRenderingMode(.alwaysTemplate)
|
||||
|
@ -105,22 +104,10 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
|
|||
return textField
|
||||
}()
|
||||
|
||||
let usernameIsTakenLabel: UILabel = {
|
||||
let usernameErrorPromptLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
let color = Asset.Colors.lightDangerRed.color
|
||||
let font = UIFont.preferredFont(forTextStyle: .caption1)
|
||||
let attributeString = NSMutableAttributedString()
|
||||
|
||||
let errorImage = NSTextAttachment()
|
||||
let configuration = UIImage.SymbolConfiguration(font: font)
|
||||
errorImage.image = UIImage(systemName: "xmark.octagon.fill", withConfiguration: configuration)?.withTintColor(color)
|
||||
let errorImageAttachment = NSAttributedString(attachment: errorImage)
|
||||
attributeString.append(errorImageAttachment)
|
||||
|
||||
let errorString = NSAttributedString(string: L10n.Common.Errors.Item.username + " " + L10n.Common.Errors.errTaken, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: color])
|
||||
attributeString.append(errorString)
|
||||
label.attributedText = attributeString
|
||||
|
||||
return label
|
||||
}()
|
||||
|
||||
|
@ -157,9 +144,10 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
|
|||
return textField
|
||||
}()
|
||||
|
||||
let passwordCheckLabel: UILabel = {
|
||||
let emailErrorPromptLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
let color = Asset.Colors.lightDangerRed.color
|
||||
let font = UIFont.preferredFont(forTextStyle: .caption1)
|
||||
return label
|
||||
}()
|
||||
|
||||
|
@ -181,7 +169,21 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
|
|||
return textField
|
||||
}()
|
||||
|
||||
lazy var inviteTextField: UITextField = {
|
||||
let passwordCheckLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
return label
|
||||
}()
|
||||
|
||||
let passwordErrorPromptLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
let color = Asset.Colors.lightDangerRed.color
|
||||
let font = UIFont.preferredFont(forTextStyle: .caption1)
|
||||
return label
|
||||
}()
|
||||
|
||||
|
||||
lazy var reasonTextField: UITextField = {
|
||||
let textField = UITextField()
|
||||
textField.autocapitalizationType = .none
|
||||
textField.autocorrectionType = .no
|
||||
|
@ -197,6 +199,13 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
|
|||
return textField
|
||||
}()
|
||||
|
||||
let reasonErrorPromptLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
let color = Asset.Colors.lightDangerRed.color
|
||||
let font = UIFont.preferredFont(forTextStyle: .caption1)
|
||||
return label
|
||||
}()
|
||||
|
||||
let buttonContainer = UIView()
|
||||
let signUpButton: PrimaryActionButton = {
|
||||
let button = PrimaryActionButton()
|
||||
|
@ -211,26 +220,16 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O
|
|||
}
|
||||
|
||||
extension MastodonRegisterViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setupOnboardingAppearance()
|
||||
defer { setupNavigationBarBackgroundView() }
|
||||
|
||||
|
||||
photoButton.publisher(for: \.isHighlighted, options: .new)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isHighlighted in
|
||||
guard let self = self else { return }
|
||||
let alpha: CGFloat = isHighlighted ? 0.8 : 1
|
||||
self.plusIcon.alpha = alpha
|
||||
self.photoButton.alpha = alpha
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
domainLabel.text = "@" + viewModel.domain + " "
|
||||
domainLabel.sizeToFit()
|
||||
passwordCheckLabel.attributedText = viewModel.attributeStringForPassword()
|
||||
passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(validateState: .empty)
|
||||
usernameTextField.rightView = domainLabel
|
||||
usernameTextField.rightViewMode = .always
|
||||
usernameTextField.delegate = self
|
||||
|
@ -250,16 +249,40 @@ extension MastodonRegisterViewController {
|
|||
stackView.layoutMargins = UIEdgeInsets(top: 20, left: 0, bottom: 26, right: 0)
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
stackView.addArrangedSubview(largeTitleLabel)
|
||||
stackView.addArrangedSubview(photoView)
|
||||
stackView.addArrangedSubview(avatarView)
|
||||
stackView.addArrangedSubview(usernameTextField)
|
||||
stackView.addArrangedSubview(usernameIsTakenLabel)
|
||||
stackView.addArrangedSubview(displayNameTextField)
|
||||
stackView.addArrangedSubview(emailTextField)
|
||||
stackView.addArrangedSubview(passwordTextField)
|
||||
stackView.addArrangedSubview(passwordCheckLabel)
|
||||
if viewModel.approvalRequired {
|
||||
stackView.addArrangedSubview(inviteTextField)
|
||||
stackView.addArrangedSubview(reasonTextField)
|
||||
}
|
||||
|
||||
usernameErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.addSubview(usernameErrorPromptLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
usernameErrorPromptLabel.topAnchor.constraint(equalTo: usernameTextField.bottomAnchor, constant: 6),
|
||||
usernameErrorPromptLabel.leadingAnchor.constraint(equalTo: usernameTextField.leadingAnchor),
|
||||
usernameErrorPromptLabel.trailingAnchor.constraint(equalTo: usernameTextField.trailingAnchor),
|
||||
])
|
||||
|
||||
emailErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.addSubview(emailErrorPromptLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
emailErrorPromptLabel.topAnchor.constraint(equalTo: emailTextField.bottomAnchor, constant: 6),
|
||||
emailErrorPromptLabel.leadingAnchor.constraint(equalTo: emailTextField.leadingAnchor),
|
||||
emailErrorPromptLabel.trailingAnchor.constraint(equalTo: emailTextField.trailingAnchor),
|
||||
])
|
||||
|
||||
passwordErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.addSubview(passwordErrorPromptLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
passwordErrorPromptLabel.topAnchor.constraint(equalTo: passwordCheckLabel.bottomAnchor, constant: 2),
|
||||
passwordErrorPromptLabel.leadingAnchor.constraint(equalTo: passwordTextField.leadingAnchor),
|
||||
passwordErrorPromptLabel.trailingAnchor.constraint(equalTo: passwordTextField.trailingAnchor),
|
||||
])
|
||||
|
||||
// scrollView
|
||||
view.addSubview(scrollView)
|
||||
NSLayoutConstraint.activate([
|
||||
|
@ -282,24 +305,24 @@ extension MastodonRegisterViewController {
|
|||
])
|
||||
|
||||
// photoview
|
||||
photoView.translatesAutoresizingMaskIntoConstraints = false
|
||||
photoView.addSubview(photoButton)
|
||||
avatarView.translatesAutoresizingMaskIntoConstraints = false
|
||||
avatarView.addSubview(avatarButton)
|
||||
NSLayoutConstraint.activate([
|
||||
photoView.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh),
|
||||
avatarView.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh),
|
||||
])
|
||||
photoButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
avatarButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
photoButton.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh),
|
||||
photoButton.widthAnchor.constraint(equalToConstant: 90).priority(.defaultHigh),
|
||||
photoButton.centerXAnchor.constraint(equalTo: photoView.centerXAnchor),
|
||||
photoButton.centerYAnchor.constraint(equalTo: photoView.centerYAnchor),
|
||||
avatarButton.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh),
|
||||
avatarButton.widthAnchor.constraint(equalToConstant: 90).priority(.defaultHigh),
|
||||
avatarButton.centerXAnchor.constraint(equalTo: avatarView.centerXAnchor),
|
||||
avatarButton.centerYAnchor.constraint(equalTo: avatarView.centerYAnchor),
|
||||
])
|
||||
|
||||
plusIcon.translatesAutoresizingMaskIntoConstraints = false
|
||||
photoView.addSubview(plusIcon)
|
||||
plusIconImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
avatarView.addSubview(plusIconImageView)
|
||||
NSLayoutConstraint.activate([
|
||||
plusIcon.trailingAnchor.constraint(equalTo: photoButton.trailingAnchor),
|
||||
plusIcon.bottomAnchor.constraint(equalTo: photoButton.bottomAnchor),
|
||||
plusIconImageView.trailingAnchor.constraint(equalTo: avatarButton.trailingAnchor),
|
||||
plusIconImageView.bottomAnchor.constraint(equalTo: avatarButton.bottomAnchor),
|
||||
])
|
||||
|
||||
// textfield
|
||||
|
@ -360,6 +383,16 @@ extension MastodonRegisterViewController {
|
|||
}
|
||||
})
|
||||
.store(in: &disposeBag)
|
||||
|
||||
avatarButton.publisher(for: \.isHighlighted, options: .new)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isHighlighted in
|
||||
guard let self = self else { return }
|
||||
let alpha: CGFloat = isHighlighted ? 0.8 : 1
|
||||
self.plusIconImageView.alpha = alpha
|
||||
self.avatarButton.alpha = alpha
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
viewModel.isRegistering
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
@ -376,6 +409,13 @@ extension MastodonRegisterViewController {
|
|||
self.setTextFieldValidAppearance(self.usernameTextField, validateState: validateState)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
viewModel.usernameErrorPrompt
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] prompt in
|
||||
guard let self = self else { return }
|
||||
self.usernameErrorPromptLabel.attributedText = prompt
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
viewModel.displayNameValidateState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] validateState in
|
||||
|
@ -390,12 +430,33 @@ extension MastodonRegisterViewController {
|
|||
self.setTextFieldValidAppearance(self.emailTextField, validateState: validateState)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
viewModel.emailErrorPrompt
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] prompt in
|
||||
guard let self = self else { return }
|
||||
self.emailErrorPromptLabel.attributedText = prompt
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
viewModel.passwordValidateState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] validateState in
|
||||
guard let self = self else { return }
|
||||
self.setTextFieldValidAppearance(self.passwordTextField, validateState: validateState)
|
||||
self.passwordCheckLabel.attributedText = self.viewModel.attributeStringForPassword(eightCharacters: validateState == .valid)
|
||||
self.passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(validateState: validateState)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
viewModel.passwordErrorPrompt
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] prompt in
|
||||
guard let self = self else { return }
|
||||
self.passwordErrorPromptLabel.attributedText = prompt
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
viewModel.reasonErrorPrompt
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] prompt in
|
||||
guard let self = self else { return }
|
||||
self.reasonErrorPromptLabel.attributedText = prompt
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
|
@ -407,37 +468,11 @@ extension MastodonRegisterViewController {
|
|||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
viewModel.isUsernameTaken
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isUsernameTaken in
|
||||
guard let self = self else { return }
|
||||
if isUsernameTaken {
|
||||
self.usernameIsTakenLabel.isHidden = false
|
||||
stackView.setCustomSpacing(6, after: self.usernameTextField)
|
||||
stackView.setCustomSpacing(16, after: self.usernameIsTakenLabel)
|
||||
} else {
|
||||
self.usernameIsTakenLabel.isHidden = true
|
||||
stackView.setCustomSpacing(40, after: self.usernameTextField)
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
viewModel.error
|
||||
.compactMap { $0 }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] error in
|
||||
guard let self = self else { return }
|
||||
guard let error = error as? Mastodon.API.Error else { return }
|
||||
switch error.mastodonError {
|
||||
case .generic(let mastodonEntityError):
|
||||
if let usernameTakenError = mastodonEntityError.details?.username {
|
||||
let isUsernameAvaliable = usernameTakenError.filter { errorDetailReason -> Bool in
|
||||
errorDetailReason.error == .ERR_TAKEN
|
||||
}.isEmpty
|
||||
self.viewModel.isUsernameTaken.value = !isUsernameAvaliable
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
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)
|
||||
|
@ -486,35 +521,45 @@ extension MastodonRegisterViewController {
|
|||
.store(in: &disposeBag)
|
||||
|
||||
if viewModel.approvalRequired {
|
||||
inviteTextField.delegate = self
|
||||
reasonTextField.delegate = self
|
||||
NSLayoutConstraint.activate([
|
||||
inviteTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh),
|
||||
reasonTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh),
|
||||
])
|
||||
reasonErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.addSubview(reasonErrorPromptLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
reasonErrorPromptLabel.topAnchor.constraint(equalTo: reasonTextField.bottomAnchor, constant: 6),
|
||||
reasonErrorPromptLabel.leadingAnchor.constraint(equalTo: reasonTextField.leadingAnchor),
|
||||
reasonErrorPromptLabel.trailingAnchor.constraint(equalTo: reasonTextField.trailingAnchor),
|
||||
])
|
||||
|
||||
viewModel.inviteValidateState
|
||||
viewModel.reasonValidateState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] validateState in
|
||||
guard let self = self else { return }
|
||||
self.setTextFieldValidAppearance(self.inviteTextField, validateState: validateState)
|
||||
self.setTextFieldValidAppearance(self.reasonTextField, validateState: validateState)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
NotificationCenter.default
|
||||
.publisher(for: UITextField.textDidChangeNotification, object: inviteTextField)
|
||||
.publisher(for: UITextField.textDidChangeNotification, object: reasonTextField)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.viewModel.reason.value = self.inviteTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
self.viewModel.reason.value = self.reasonTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
avatarButton.addTarget(self, action: #selector(MastodonRegisterViewController.avatarButtonPressed(_:)), for: .touchUpInside)
|
||||
signUpButton.addTarget(self, action: #selector(MastodonRegisterViewController.signUpButtonPressed(_:)), for: .touchUpInside)
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
plusIcon.layer.cornerRadius = plusIcon.frame.width/2
|
||||
plusIcon.clipsToBounds = true
|
||||
plusIconImageView.layer.cornerRadius = plusIconImageView.frame.width / 2
|
||||
plusIconImageView.layer.masksToBounds = true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonRegisterViewController: UITextFieldDelegate {
|
||||
|
@ -530,7 +575,7 @@ extension MastodonRegisterViewController: UITextFieldDelegate {
|
|||
viewModel.email.value = text
|
||||
case passwordTextField:
|
||||
viewModel.password.value = text
|
||||
case inviteTextField:
|
||||
case reasonTextField:
|
||||
viewModel.reason.value = text
|
||||
default:
|
||||
break
|
||||
|
|
|
@ -26,6 +26,11 @@ final class MastodonRegisterViewModel {
|
|||
let reason = CurrentValueSubject<String, Never>("")
|
||||
let avatarImage = CurrentValueSubject<UIImage?, Never>(nil)
|
||||
|
||||
let usernameErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil)
|
||||
let emailErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil)
|
||||
let passwordErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil)
|
||||
let reasonErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil)
|
||||
|
||||
// output
|
||||
let approvalRequired: Bool
|
||||
let applicationAuthorization: Mastodon.API.OAuth.Authorization
|
||||
|
@ -33,10 +38,8 @@ final class MastodonRegisterViewModel {
|
|||
let displayNameValidateState = CurrentValueSubject<ValidateState, Never>(.empty)
|
||||
let emailValidateState = CurrentValueSubject<ValidateState, Never>(.empty)
|
||||
let passwordValidateState = CurrentValueSubject<ValidateState, Never>(.empty)
|
||||
let inviteValidateState = CurrentValueSubject<ValidateState, Never>(.empty)
|
||||
|
||||
let isUsernameTaken = CurrentValueSubject<Bool, Never>(false)
|
||||
|
||||
let reasonValidateState = CurrentValueSubject<ValidateState, Never>(.empty)
|
||||
|
||||
let isRegistering = CurrentValueSubject<Bool, Never>(false)
|
||||
let isAllValid = CurrentValueSubject<Bool, Never>(false)
|
||||
let error = CurrentValueSubject<Error?, Never>(nil)
|
||||
|
@ -102,25 +105,43 @@ final class MastodonRegisterViewModel {
|
|||
guard !invite.isEmpty else { return .empty }
|
||||
return .valid
|
||||
}
|
||||
.assign(to: \.value, on: inviteValidateState)
|
||||
.assign(to: \.value, on: reasonValidateState)
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
error
|
||||
.sink { [weak self] error in
|
||||
guard let self = self else { return }
|
||||
let error = error as? Mastodon.API.Error
|
||||
let mastodonError = error?.mastodonError
|
||||
if case let .generic(genericMastodonError) = mastodonError,
|
||||
let details = genericMastodonError.details {
|
||||
self.usernameErrorPrompt.value = details.usernameErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) }
|
||||
self.emailErrorPrompt.value = details.emailErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) }
|
||||
self.passwordErrorPrompt.value = details.passwordErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) }
|
||||
self.reasonErrorPrompt.value = details.reasonErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) }
|
||||
} else {
|
||||
self.usernameErrorPrompt.value = nil
|
||||
self.emailErrorPrompt.value = nil
|
||||
self.passwordErrorPrompt.value = nil
|
||||
self.reasonErrorPrompt.value = nil
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
let publisherOne = Publishers.CombineLatest4(
|
||||
usernameValidateState.eraseToAnyPublisher(),
|
||||
displayNameValidateState.eraseToAnyPublisher(),
|
||||
emailValidateState.eraseToAnyPublisher(),
|
||||
passwordValidateState.eraseToAnyPublisher()
|
||||
).map {
|
||||
$0.0 == .valid && $0.1 == .valid && $0.2 == .valid && $0.3 == .valid
|
||||
}
|
||||
)
|
||||
.map { $0.0 == .valid && $0.1 == .valid && $0.2 == .valid && $0.3 == .valid }
|
||||
|
||||
Publishers.CombineLatest(
|
||||
publisherOne,
|
||||
approvalRequired ? inviteValidateState.map {$0 == .valid}.eraseToAnyPublisher() : Just(true).eraseToAnyPublisher()
|
||||
approvalRequired ? reasonValidateState.map {$0 == .valid}.eraseToAnyPublisher() : Just(true).eraseToAnyPublisher()
|
||||
)
|
||||
.map {
|
||||
return $0 && $1
|
||||
}
|
||||
.map { $0 && $1 }
|
||||
.assign(to: \.value, on: isAllValid)
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
@ -135,6 +156,7 @@ extension MastodonRegisterViewModel {
|
|||
}
|
||||
|
||||
extension MastodonRegisterViewModel {
|
||||
|
||||
static func isValidEmail(_ email: String) -> Bool {
|
||||
let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
|
||||
|
||||
|
@ -142,37 +164,47 @@ extension MastodonRegisterViewModel {
|
|||
return emailPred.evaluate(with: email)
|
||||
}
|
||||
|
||||
func attributeStringForUsername() -> NSAttributedString {
|
||||
let resultAttributeString = NSMutableAttributedString()
|
||||
let redImage = NSTextAttachment()
|
||||
let font = UIFont.preferredFont(forTextStyle: .caption1)
|
||||
static func checkmarkImage(font: UIFont = .preferredFont(forTextStyle: .caption1)) -> UIImage {
|
||||
let configuration = UIImage.SymbolConfiguration(font: font)
|
||||
redImage.image = UIImage(systemName: "xmark.octagon.fill", withConfiguration: configuration)?.withTintColor(Asset.Colors.lightDangerRed.color)
|
||||
let imageAttribute = NSAttributedString(attachment: redImage)
|
||||
let stringAttribute = NSAttributedString(string: "This username is taken.", attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: Asset.Colors.lightDangerRed.color])
|
||||
resultAttributeString.append(imageAttribute)
|
||||
resultAttributeString.append(stringAttribute)
|
||||
return resultAttributeString
|
||||
return UIImage(systemName: "checkmark.circle.fill", withConfiguration: configuration)!
|
||||
}
|
||||
|
||||
static func xmarkImage(font: UIFont = .preferredFont(forTextStyle: .caption1)) -> UIImage {
|
||||
let configuration = UIImage.SymbolConfiguration(font: font)
|
||||
return UIImage(systemName: "xmark.octagon.fill", withConfiguration: configuration)!
|
||||
}
|
||||
|
||||
func attributeStringForPassword(eightCharacters: Bool = false) -> NSAttributedString {
|
||||
static func attributedStringImage(with image: UIImage, tintColor: UIColor) -> NSAttributedString {
|
||||
let attachment = NSTextAttachment()
|
||||
attachment.image = image.withTintColor(tintColor)
|
||||
return NSAttributedString(attachment: attachment)
|
||||
}
|
||||
|
||||
static func attributeStringForPassword(validateState: ValidateState) -> NSAttributedString {
|
||||
let font = UIFont.preferredFont(forTextStyle: .caption1)
|
||||
let color = UIColor.black
|
||||
let falseColor = UIColor.clear
|
||||
let attributeString = NSMutableAttributedString()
|
||||
|
||||
attributeString.append(checkmarkImage(color: eightCharacters ? color : falseColor))
|
||||
let eightCharactersDescription = NSAttributedString(string: L10n.Scene.Register.Input.Password.hint, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: color])
|
||||
|
||||
let image = MastodonRegisterViewModel.checkmarkImage(font: font)
|
||||
attributeString.append(attributedStringImage(with: image, tintColor: validateState == .valid ? .black : .clear))
|
||||
attributeString.append(NSAttributedString(string: " "))
|
||||
let eightCharactersDescription = NSAttributedString(string: L10n.Scene.Register.Input.Password.hint, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: UIColor.black])
|
||||
attributeString.append(eightCharactersDescription)
|
||||
|
||||
return attributeString
|
||||
}
|
||||
|
||||
func checkmarkImage(color: UIColor) -> NSAttributedString {
|
||||
let checkmarkImage = NSTextAttachment()
|
||||
|
||||
static func errorPromptAttributedString(for prompt: String) -> NSAttributedString {
|
||||
let font = UIFont.preferredFont(forTextStyle: .caption1)
|
||||
let configuration = UIImage.SymbolConfiguration(font: font)
|
||||
checkmarkImage.image = UIImage(systemName: "checkmark.circle.fill", withConfiguration: configuration)?.withTintColor(color)
|
||||
return NSAttributedString(attachment: checkmarkImage)
|
||||
let attributeString = NSMutableAttributedString()
|
||||
|
||||
let image = MastodonRegisterViewModel.xmarkImage(font: font)
|
||||
attributeString.append(attributedStringImage(with: image, tintColor: Asset.Colors.lightDangerRed.color))
|
||||
attributeString.append(NSAttributedString(string: " "))
|
||||
|
||||
let promptAttributedString = NSAttributedString(string: prompt, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: Asset.Colors.lightDangerRed.color])
|
||||
attributeString.append(promptAttributedString)
|
||||
|
||||
return attributeString
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -42,21 +42,7 @@ final class AuthenticationViewModel {
|
|||
|
||||
input
|
||||
.map { input in
|
||||
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
let urlString = trimmed.hasPrefix("https://") ? trimmed : "https://" + trimmed
|
||||
guard let url = URL(string: urlString),
|
||||
let host = url.host else {
|
||||
return nil
|
||||
}
|
||||
let components = host.components(separatedBy: ".")
|
||||
guard !components.contains(where: { $0.isEmpty }) else { return nil }
|
||||
guard components.count >= 2 else { return nil }
|
||||
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: iput host: %s", ((#file as NSString).lastPathComponent), #line, #function, host)
|
||||
|
||||
return host
|
||||
AuthenticationViewModel.parseDomain(from: input)
|
||||
}
|
||||
.assign(to: \.value, on: domain)
|
||||
.store(in: &disposeBag)
|
||||
|
@ -77,6 +63,26 @@ final class AuthenticationViewModel {
|
|||
|
||||
}
|
||||
|
||||
extension AuthenticationViewModel {
|
||||
static func parseDomain(from input: String) -> String? {
|
||||
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
let urlString = trimmed.hasPrefix("https://") ? trimmed : "https://" + trimmed
|
||||
guard let url = URL(string: urlString),
|
||||
let host = url.host else {
|
||||
return nil
|
||||
}
|
||||
let components = host.components(separatedBy: ".")
|
||||
guard !components.contains(where: { $0.isEmpty }) else { return nil }
|
||||
guard components.count >= 2 else { return nil }
|
||||
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: input host: %s", ((#file as NSString).lastPathComponent), #line, #function, host)
|
||||
|
||||
return host
|
||||
}
|
||||
}
|
||||
|
||||
extension AuthenticationViewModel {
|
||||
enum AuthenticationError: Error, LocalizedError {
|
||||
case badCredentials
|
||||
|
|
|
@ -23,7 +23,7 @@ extension APIService {
|
|||
return Mastodon.API.Onboarding.categories(session: session)
|
||||
}
|
||||
|
||||
func stubCategories() -> [Mastodon.Entity.Category] {
|
||||
static func stubCategories() -> [Mastodon.Entity.Category] {
|
||||
return Mastodon.Entity.Category.Kind.allCases.map { kind in
|
||||
return Mastodon.Entity.Category(category: kind.rawValue, serversCount: 0)
|
||||
}
|
||||
|
|
|
@ -34,27 +34,3 @@ extension Mastodon.API {
|
|||
|
||||
}
|
||||
}
|
||||
|
||||
extension Mastodon.API.Error: LocalizedError {
|
||||
|
||||
public var errorDescription: String? {
|
||||
guard let mastodonError = mastodonError else {
|
||||
return "HTTP \(httpResponseStatus.code)"
|
||||
}
|
||||
switch mastodonError {
|
||||
case .generic(let error):
|
||||
return error.error
|
||||
}
|
||||
}
|
||||
|
||||
public var failureReason: String? {
|
||||
guard let mastodonError = mastodonError else {
|
||||
return httpResponseStatus.reasonPhrase
|
||||
}
|
||||
switch mastodonError {
|
||||
case .generic(let error):
|
||||
return error.errorDescription
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -6,27 +6,70 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Mastodon.Entity.Error {
|
||||
/// ERR_BLOCKED When e-mail provider is not allowed
|
||||
/// ERR_UNREACHABLE When e-mail address does not resolve to any IP via DNS (MX, A, AAAA)
|
||||
/// ERR_TAKEN When username or e-mail are already taken
|
||||
/// ERR_RESERVED When a username is reserved, e.g. "webmaster" or "admin"
|
||||
/// ERR_ACCEPTED When agreement has not been accepted
|
||||
/// ERR_BLANK When a required attribute is blank
|
||||
/// ERR_INVALID When an attribute is malformed, e.g. wrong characters or invalid e-mail address
|
||||
/// ERR_TOO_LONG When an attribute is over the character limit
|
||||
/// ERR_INCLUSION When an attribute is not one of the allowed values, e.g. unsupported locale
|
||||
public enum SignUpError: RawRepresentable, Codable {
|
||||
public struct Detail: Codable {
|
||||
public let username: [Reason]?
|
||||
public let email: [Reason]?
|
||||
public let password: [Reason]?
|
||||
public let agreement: [Reason]?
|
||||
public let locale: [Reason]?
|
||||
public let reason: [Reason]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case username
|
||||
case email
|
||||
case password
|
||||
case agreement
|
||||
case locale
|
||||
case reason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
extension Mastodon.Entity.Error.Detail {
|
||||
public struct Reason: Codable {
|
||||
public let error: Error
|
||||
public let description: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case error
|
||||
case description
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Mastodon.Entity.Error.Detail.Reason {
|
||||
/// - Since: 3.3.1
|
||||
/// - Version: 3.3.1
|
||||
/// # Last Update
|
||||
/// 2021/3/4
|
||||
/// # Reference
|
||||
/// [Document](https://github.com/tootsuite/mastodon/pull/15803)
|
||||
public enum Error: RawRepresentable, Codable {
|
||||
/// When e-mail provider is not allowed
|
||||
case ERR_BLOCKED
|
||||
/// When e-mail address does not resolve to any IP via DNS (MX, A, AAAA)
|
||||
case ERR_UNREACHABLE
|
||||
/// When username or e-mail are already taken
|
||||
case ERR_TAKEN
|
||||
/// When a username is reserved, e.g. "webmaster" or "admin"
|
||||
case ERR_RESERVED
|
||||
/// When agreement has not been accepted
|
||||
case ERR_ACCEPTED
|
||||
/// When a required attribute is blank
|
||||
case ERR_BLANK
|
||||
/// When an attribute is malformed, e.g. wrong characters or invalid e-mail address
|
||||
case ERR_INVALID
|
||||
/// When an attribute is over the character limit
|
||||
case ERR_TOO_LONG
|
||||
/// When an attribute is under the character requirement
|
||||
case ERR_TOO_SHORT
|
||||
/// When an attribute is not one of the allowed values, e.g. unsupported locale
|
||||
case ERR_INCLUSION
|
||||
/// Not handled error
|
||||
case _other(String)
|
||||
|
||||
public init?(rawValue: String) {
|
||||
|
@ -65,38 +108,3 @@ extension Mastodon.Entity.Error {
|
|||
}
|
||||
}
|
||||
}
|
||||
extension Mastodon.Entity {
|
||||
public struct ErrorDetail: Codable {
|
||||
public let username: [ErrorDetailReason]?
|
||||
public let email: [ErrorDetailReason]?
|
||||
public let password: [ErrorDetailReason]?
|
||||
public let agreement: [ErrorDetailReason]?
|
||||
public let locale: [ErrorDetailReason]?
|
||||
public let reason: [ErrorDetailReason]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case username
|
||||
case email
|
||||
case password
|
||||
case agreement
|
||||
case locale
|
||||
case reason
|
||||
}
|
||||
}
|
||||
|
||||
public struct ErrorDetailReason: Codable {
|
||||
public init(error: String, errorDescription: String?) {
|
||||
self.error = Mastodon.Entity.Error.SignUpError(rawValue: error) ?? ._other(error)
|
||||
self.errorDescription = errorDescription
|
||||
}
|
||||
|
||||
public let error: Mastodon.Entity.Error.SignUpError
|
||||
public let errorDescription: String?
|
||||
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case error
|
||||
case errorDescription = "description"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -13,13 +13,13 @@ extension Mastodon.Entity {
|
|||
/// - Since: 0.6.0
|
||||
/// - Version: 3.3.0
|
||||
/// # Last Update
|
||||
/// 2021/1/28
|
||||
/// 2021/3/4
|
||||
/// # Reference
|
||||
/// [Document](https://docs.joinmastodon.org/entities/error/)
|
||||
public struct Error: Codable {
|
||||
public let error: String
|
||||
public let errorDescription: String?
|
||||
public let details: ErrorDetail?
|
||||
public let details: Detail?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case error
|
||||
|
|
|
@ -39,10 +39,23 @@ extension Mastodon.Response {
|
|||
}()
|
||||
}
|
||||
|
||||
init<O>(value: T, old: Mastodon.Response.Content<O>) {
|
||||
self.value = value
|
||||
self.date = old.date
|
||||
self.rateLimit = old.rateLimit
|
||||
self.responseTime = old.responseTime
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
extension Mastodon.Response.Content {
|
||||
public func map<R>(_ transform: (T) -> R) -> Mastodon.Response.Content<R> {
|
||||
return Mastodon.Response.Content(value: transform(value), old: self)
|
||||
}
|
||||
}
|
||||
|
||||
extension Mastodon.Response {
|
||||
public struct RateLimit {
|
||||
|
||||
public let limit: Int
|
||||
|
|
Loading…
Reference in New Issue