Merge pull request #23 from tootsuite/feat/serverPicker
Feat/server picker
This commit is contained in:
commit
2498254c38
|
@ -0,0 +1,95 @@
|
||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"alerts": {},
|
||||||
|
"controls": {
|
||||||
|
"actions": {
|
||||||
|
"add": "Add",
|
||||||
|
"remove": "Remove",
|
||||||
|
"edit": "Edit",
|
||||||
|
"save": "Save",
|
||||||
|
"ok": "OK",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"continue": "Continue",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"take_photo": "Take photo",
|
||||||
|
"save_photo": "Save photo",
|
||||||
|
"sign_in": "Sign in",
|
||||||
|
"sign_up": "Sign up",
|
||||||
|
"see_more": "See More",
|
||||||
|
"preview": "Preview",
|
||||||
|
"open_in_safari": "Open in Safari"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"user_boosted": "%s boosted",
|
||||||
|
"content_warning": "content warning",
|
||||||
|
"show_post": "Show Post"
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"load_more": "Load More"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"countable": {
|
||||||
|
"photo": {
|
||||||
|
"single": "photo",
|
||||||
|
"multiple": "photos"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scene": {
|
||||||
|
"welcome": {
|
||||||
|
"slogan": "Social networking\nback in your hands."
|
||||||
|
},
|
||||||
|
"server_picker": {
|
||||||
|
"title": "Pick a Server,\nany server.",
|
||||||
|
"Button": {
|
||||||
|
"Category": {
|
||||||
|
"All": "All"
|
||||||
|
},
|
||||||
|
"SeeLess": "See Less",
|
||||||
|
"SeeMore": "See More"
|
||||||
|
},
|
||||||
|
"Label": {
|
||||||
|
"Language": "LANGUAGE",
|
||||||
|
"Users": "USERS",
|
||||||
|
"Category": "CATEGORY"
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
"placeholder": "Find a server or join your own..."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"title": "Tell us about you.",
|
||||||
|
"input": {
|
||||||
|
"username": {
|
||||||
|
"placeholder": "username",
|
||||||
|
"duplicate_prompt": "This username is taken."
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"placeholder": "display name"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"placeholder": "email"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"placeholder": "password",
|
||||||
|
"prompt": "Your password needs at least:",
|
||||||
|
"prompt_eight_characters": "Eight characters"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server_rules": {
|
||||||
|
"title": "Some ground rules.",
|
||||||
|
"subtitle": "These rules are set by the admins of %s.",
|
||||||
|
"prompt": "By continuing, you're subject to the terms of service and privacy policy for %s.",
|
||||||
|
"button": {
|
||||||
|
"confirm": "I Agree"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home_timeline": {
|
||||||
|
"title": "Home"
|
||||||
|
},
|
||||||
|
"public_timeline": {
|
||||||
|
"title": "Public"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
"Common.Controls.Actions.Add" = "Add";
|
||||||
|
"Common.Controls.Actions.Cancel" = "Cancel";
|
||||||
|
"Common.Controls.Actions.Confirm" = "Confirm";
|
||||||
|
"Common.Controls.Actions.Continue" = "Continue";
|
||||||
|
"Common.Controls.Actions.Edit" = "Edit";
|
||||||
|
"Common.Controls.Actions.Ok" = "OK";
|
||||||
|
"Common.Controls.Actions.OpenInSafari" = "Open in Safari";
|
||||||
|
"Common.Controls.Actions.Preview" = "Preview";
|
||||||
|
"Common.Controls.Actions.Remove" = "Remove";
|
||||||
|
"Common.Controls.Actions.Save" = "Save";
|
||||||
|
"Common.Controls.Actions.SavePhoto" = "Save photo";
|
||||||
|
"Common.Controls.Actions.SeeMore" = "See More";
|
||||||
|
"Common.Controls.Actions.SignIn" = "Sign in";
|
||||||
|
"Common.Controls.Actions.SignUp" = "Sign up";
|
||||||
|
"Common.Controls.Actions.TakePhoto" = "Take photo";
|
||||||
|
"Common.Controls.Status.ContentWarning" = "content warning";
|
||||||
|
"Common.Controls.Status.ShowPost" = "Show Post";
|
||||||
|
"Common.Controls.Status.UserBoosted" = "%@ boosted";
|
||||||
|
"Common.Controls.Timeline.LoadMore" = "Load More";
|
||||||
|
"Common.Countable.Photo.Multiple" = "photos";
|
||||||
|
"Common.Countable.Photo.Single" = "photo";
|
||||||
|
"Scene.HomeTimeline.Title" = "Home";
|
||||||
|
"Scene.PublicTimeline.Title" = "Public";
|
||||||
|
"Scene.Register.Input.DisplayName.Placeholder" = "display name";
|
||||||
|
"Scene.Register.Input.Email.Placeholder" = "email";
|
||||||
|
"Scene.Register.Input.Password.Placeholder" = "password";
|
||||||
|
"Scene.Register.Input.Password.Prompt" = "Your password needs at least:";
|
||||||
|
"Scene.Register.Input.Password.PromptEightCharacters" = "Eight characters";
|
||||||
|
"Scene.Register.Input.Username.DuplicatePrompt" = "This username is taken.";
|
||||||
|
"Scene.Register.Input.Username.Placeholder" = "username";
|
||||||
|
"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.Input.Placeholder" = "Find a server or join your own...";
|
||||||
|
"Scene.ServerPicker.Label.Category" = "CATEGORY";
|
||||||
|
"Scene.ServerPicker.Label.Language" = "LANGUAGE";
|
||||||
|
"Scene.ServerPicker.Label.Users" = "USERS";
|
||||||
|
"Scene.ServerPicker.Title" = "Pick a Server,
|
||||||
|
any server.";
|
||||||
|
"Scene.ServerRules.Button.Confirm" = "I Agree";
|
||||||
|
"Scene.ServerRules.Prompt" = "By continuing, you're subject to the terms of service and privacy policy for %@.";
|
||||||
|
"Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@.";
|
||||||
|
"Scene.ServerRules.Title" = "Some ground rules.";
|
||||||
|
"Scene.Welcome.Slogan" = "Social networking
|
||||||
|
back in your hands.";
|
|
@ -51,6 +51,18 @@
|
||||||
},
|
},
|
||||||
"server_picker": {
|
"server_picker": {
|
||||||
"title": "Pick a Server,\nany server.",
|
"title": "Pick a Server,\nany server.",
|
||||||
|
"Button": {
|
||||||
|
"Category": {
|
||||||
|
"All": "All"
|
||||||
|
},
|
||||||
|
"SeeLess": "See Less",
|
||||||
|
"SeeMore": "See More"
|
||||||
|
},
|
||||||
|
"Label": {
|
||||||
|
"Language": "LANGUAGE",
|
||||||
|
"Users": "USERS",
|
||||||
|
"Category": "CATEGORY"
|
||||||
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"placeholder": "Find a server or join your own..."
|
"placeholder": "Find a server or join your own..."
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,13 @@
|
||||||
0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */; };
|
0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */; };
|
||||||
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101B25E10E760017CCDE /* UIFont.swift */; };
|
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101B25E10E760017CCDE /* UIFont.swift */; };
|
||||||
0FAA102725E1126A0017CCDE /* PickServerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA102625E1126A0017CCDE /* PickServerViewController.swift */; };
|
0FAA102725E1126A0017CCDE /* PickServerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA102625E1126A0017CCDE /* PickServerViewController.swift */; };
|
||||||
|
0FB3D2F725E4C24D00AAD544 /* PickServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D2F625E4C24D00AAD544 /* PickServerViewModel.swift */; };
|
||||||
|
0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D2FD25E4CB6400AAD544 /* PickServerTitleCell.swift */; };
|
||||||
|
0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D30725E524C600AAD544 /* PickServerCategoriesCell.swift */; };
|
||||||
|
0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D30E25E525CD00AAD544 /* PickServerCategoryView.swift */; };
|
||||||
|
0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D31D25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift */; };
|
||||||
|
0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */; };
|
||||||
|
0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D33725E6401400AAD544 /* PickServerCell.swift */; };
|
||||||
18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; };
|
18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; };
|
||||||
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; };
|
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; };
|
||||||
2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; };
|
2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; };
|
||||||
|
@ -211,6 +218,13 @@
|
||||||
0FAA101125E105390017CCDE /* PrimaryActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryActionButton.swift; sourceTree = "<group>"; };
|
0FAA101125E105390017CCDE /* PrimaryActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryActionButton.swift; sourceTree = "<group>"; };
|
||||||
0FAA101B25E10E760017CCDE /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; };
|
0FAA101B25E10E760017CCDE /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; };
|
||||||
0FAA102625E1126A0017CCDE /* PickServerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerViewController.swift; sourceTree = "<group>"; };
|
0FAA102625E1126A0017CCDE /* PickServerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerViewController.swift; sourceTree = "<group>"; };
|
||||||
|
0FB3D2F625E4C24D00AAD544 /* PickServerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
0FB3D2FD25E4CB6400AAD544 /* PickServerTitleCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerTitleCell.swift; sourceTree = "<group>"; };
|
||||||
|
0FB3D30725E524C600AAD544 /* PickServerCategoriesCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCategoriesCell.swift; sourceTree = "<group>"; };
|
||||||
|
0FB3D30E25E525CD00AAD544 /* PickServerCategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCategoryView.swift; sourceTree = "<group>"; };
|
||||||
|
0FB3D31D25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCategoryCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
|
0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSearchCell.swift; sourceTree = "<group>"; };
|
||||||
|
0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; sourceTree = "<group>"; };
|
||||||
2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = "<group>"; };
|
2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = "<group>"; };
|
||||||
2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = "<group>"; };
|
2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = "<group>"; };
|
||||||
2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; };
|
2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; };
|
||||||
|
@ -428,11 +442,42 @@
|
||||||
0FAA102525E1125D0017CCDE /* PickServer */ = {
|
0FAA102525E1125D0017CCDE /* PickServer */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
0FB3D31825E525DE00AAD544 /* CollectionViewCell */,
|
||||||
|
0FB3D30D25E525C000AAD544 /* View */,
|
||||||
|
0FB3D2FC25E4CB4B00AAD544 /* TableViewCell */,
|
||||||
0FAA102625E1126A0017CCDE /* PickServerViewController.swift */,
|
0FAA102625E1126A0017CCDE /* PickServerViewController.swift */,
|
||||||
|
0FB3D2F625E4C24D00AAD544 /* PickServerViewModel.swift */,
|
||||||
);
|
);
|
||||||
path = PickServer;
|
path = PickServer;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
0FB3D2FC25E4CB4B00AAD544 /* TableViewCell */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
0FB3D2FD25E4CB6400AAD544 /* PickServerTitleCell.swift */,
|
||||||
|
0FB3D30725E524C600AAD544 /* PickServerCategoriesCell.swift */,
|
||||||
|
0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */,
|
||||||
|
0FB3D33725E6401400AAD544 /* PickServerCell.swift */,
|
||||||
|
);
|
||||||
|
path = TableViewCell;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
0FB3D30D25E525C000AAD544 /* View */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
0FB3D30E25E525CD00AAD544 /* PickServerCategoryView.swift */,
|
||||||
|
);
|
||||||
|
path = View;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
0FB3D31825E525DE00AAD544 /* CollectionViewCell */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
0FB3D31D25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift */,
|
||||||
|
);
|
||||||
|
path = CollectionViewCell;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
1EBA4F56E920856A3FC84ACB /* Pods */ = {
|
1EBA4F56E920856A3FC84ACB /* Pods */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -1367,8 +1412,10 @@
|
||||||
2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */,
|
2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */,
|
||||||
DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */,
|
DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */,
|
||||||
2D7631B325C159F700929FB9 /* Item.swift in Sources */,
|
2D7631B325C159F700929FB9 /* Item.swift in Sources */,
|
||||||
|
0FB3D2F725E4C24D00AAD544 /* PickServerViewModel.swift in Sources */,
|
||||||
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */,
|
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */,
|
||||||
2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */,
|
2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */,
|
||||||
|
0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */,
|
||||||
2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */,
|
2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */,
|
||||||
0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */,
|
0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */,
|
||||||
DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */,
|
DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */,
|
||||||
|
@ -1403,6 +1450,7 @@
|
||||||
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */,
|
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */,
|
||||||
2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */,
|
2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */,
|
||||||
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */,
|
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */,
|
||||||
|
0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */,
|
||||||
2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */,
|
2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */,
|
||||||
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */,
|
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */,
|
||||||
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */,
|
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */,
|
||||||
|
@ -1418,6 +1466,8 @@
|
||||||
DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */,
|
DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */,
|
||||||
DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */,
|
DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */,
|
||||||
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */,
|
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */,
|
||||||
|
0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */,
|
||||||
|
0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */,
|
||||||
2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */,
|
2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */,
|
||||||
DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */,
|
DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */,
|
||||||
DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */,
|
DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */,
|
||||||
|
@ -1461,7 +1511,10 @@
|
||||||
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */,
|
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */,
|
||||||
2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */,
|
2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */,
|
||||||
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */,
|
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */,
|
||||||
|
0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */,
|
||||||
2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */,
|
2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */,
|
||||||
|
2D5A3D1125CF87AA002347D6 /* AvatarBarButtonItem.swift in Sources */,
|
||||||
|
0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */,
|
||||||
DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */,
|
DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|
|
@ -39,6 +39,7 @@ extension SceneCoordinator {
|
||||||
|
|
||||||
enum Scene {
|
enum Scene {
|
||||||
case welcome
|
case welcome
|
||||||
|
case pickServer(viewMode: PickServerViewModel)
|
||||||
case authentication(viewModel: AuthenticationViewModel)
|
case authentication(viewModel: AuthenticationViewModel)
|
||||||
case mastodonPinBasedAuthentication(viewModel: MastodonPinBasedAuthenticationViewModel)
|
case mastodonPinBasedAuthentication(viewModel: MastodonPinBasedAuthenticationViewModel)
|
||||||
case mastodonRegister(viewModel: MastodonRegisterViewModel)
|
case mastodonRegister(viewModel: MastodonRegisterViewModel)
|
||||||
|
@ -142,6 +143,10 @@ private extension SceneCoordinator {
|
||||||
case .welcome:
|
case .welcome:
|
||||||
let _viewController = WelcomeViewController()
|
let _viewController = WelcomeViewController()
|
||||||
viewController = _viewController
|
viewController = _viewController
|
||||||
|
case .pickServer(let viewModel):
|
||||||
|
let _viewController = PickServerViewController()
|
||||||
|
_viewController.viewModel = viewModel
|
||||||
|
viewController = _viewController
|
||||||
case .authentication(let viewModel):
|
case .authentication(let viewModel):
|
||||||
let _viewController = AuthenticationViewController()
|
let _viewController = AuthenticationViewController()
|
||||||
_viewController.viewModel = viewModel
|
_viewController.viewModel = viewModel
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
// UIFont.swift
|
// UIFont.swift
|
||||||
// Mastodon
|
// Mastodon
|
||||||
//
|
//
|
||||||
// Created by 高原 on 2021/2/20.
|
// Created by BradGao on 2021/2/20.
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
|
@ -31,4 +31,28 @@ extension UIView {
|
||||||
layer.cornerCurve = .continuous
|
layer.cornerCurve = .continuous
|
||||||
return self
|
return self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func applyShadow(
|
||||||
|
color: UIColor,
|
||||||
|
alpha: Float,
|
||||||
|
x: CGFloat,
|
||||||
|
y: CGFloat,
|
||||||
|
blur: CGFloat,
|
||||||
|
spread: CGFloat = 0) -> Self
|
||||||
|
{
|
||||||
|
layer.masksToBounds = false
|
||||||
|
layer.shadowColor = color.cgColor
|
||||||
|
layer.shadowOpacity = alpha
|
||||||
|
layer.shadowOffset = CGSize(width: x, height: y)
|
||||||
|
layer.shadowRadius = blur / 2.0
|
||||||
|
if spread == 0 {
|
||||||
|
layer.shadowPath = nil
|
||||||
|
} else {
|
||||||
|
let dx = -spread
|
||||||
|
let rect = bounds.insetBy(dx: dx, dy: dx)
|
||||||
|
layer.shadowPath = UIBezierPath(rect: rect).cgPath
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -160,10 +160,28 @@ internal enum L10n {
|
||||||
internal enum ServerPicker {
|
internal enum ServerPicker {
|
||||||
/// Pick a Server,\nany server.
|
/// Pick a Server,\nany server.
|
||||||
internal static let title = L10n.tr("Localizable", "Scene.ServerPicker.Title")
|
internal static let title = L10n.tr("Localizable", "Scene.ServerPicker.Title")
|
||||||
|
internal enum Button {
|
||||||
|
/// See Less
|
||||||
|
internal static let seeless = L10n.tr("Localizable", "Scene.ServerPicker.Button.Seeless")
|
||||||
|
/// See More
|
||||||
|
internal static let seemore = L10n.tr("Localizable", "Scene.ServerPicker.Button.Seemore")
|
||||||
|
internal enum Category {
|
||||||
|
/// All
|
||||||
|
internal static let all = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.All")
|
||||||
|
}
|
||||||
|
}
|
||||||
internal enum Input {
|
internal enum Input {
|
||||||
/// Find a server or join your own...
|
/// Find a server or join your own...
|
||||||
internal static let placeholder = L10n.tr("Localizable", "Scene.ServerPicker.Input.Placeholder")
|
internal static let placeholder = L10n.tr("Localizable", "Scene.ServerPicker.Input.Placeholder")
|
||||||
}
|
}
|
||||||
|
internal enum Label {
|
||||||
|
/// CATEGORY
|
||||||
|
internal static let category = L10n.tr("Localizable", "Scene.ServerPicker.Label.Category")
|
||||||
|
/// LANGUAGE
|
||||||
|
internal static let language = L10n.tr("Localizable", "Scene.ServerPicker.Label.Language")
|
||||||
|
/// USERS
|
||||||
|
internal static let users = L10n.tr("Localizable", "Scene.ServerPicker.Label.Users")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
internal enum ServerRules {
|
internal enum ServerRules {
|
||||||
/// By continuing, you're subject to the terms of service and privacy policy for %@.
|
/// By continuing, you're subject to the terms of service and privacy policy for %@.
|
||||||
|
|
|
@ -47,7 +47,13 @@ tap the link to confirm your account.";
|
||||||
"Scene.Register.Input.Username.Placeholder" = "username";
|
"Scene.Register.Input.Username.Placeholder" = "username";
|
||||||
"Scene.Register.Success" = "Success";
|
"Scene.Register.Success" = "Success";
|
||||||
"Scene.Register.Title" = "Tell us about you.";
|
"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.Input.Placeholder" = "Find a server or join your own...";
|
"Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own...";
|
||||||
|
"Scene.ServerPicker.Label.Category" = "CATEGORY";
|
||||||
|
"Scene.ServerPicker.Label.Language" = "LANGUAGE";
|
||||||
|
"Scene.ServerPicker.Label.Users" = "USERS";
|
||||||
"Scene.ServerPicker.Title" = "Pick a Server,
|
"Scene.ServerPicker.Title" = "Pick a Server,
|
||||||
any server.";
|
any server.";
|
||||||
"Scene.ServerRules.Button.Confirm" = "I Agree";
|
"Scene.ServerRules.Button.Confirm" = "I Agree";
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
//
|
||||||
|
// PickServerCategoryCollectionViewCell.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/2/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class PickServerCategoryCollectionViewCell: UICollectionViewCell {
|
||||||
|
|
||||||
|
var category: PickServerViewModel.Category? {
|
||||||
|
didSet {
|
||||||
|
categoryView.category = category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var categoryView: PickServerCategoryView = {
|
||||||
|
let view = PickServerCategoryView()
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
override var isSelected: Bool {
|
||||||
|
didSet {
|
||||||
|
categoryView.selected = isSelected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
configure()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
configure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PickServerCategoryCollectionViewCell {
|
||||||
|
private func configure() {
|
||||||
|
contentView.addSubview(categoryView)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
categoryView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||||
|
categoryView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||||
|
categoryView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
|
||||||
|
contentView.bottomAnchor.constraint(equalTo: categoryView.bottomAnchor, constant: 10),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,20 +2,418 @@
|
||||||
// PickServerViewController.swift
|
// PickServerViewController.swift
|
||||||
// Mastodon
|
// Mastodon
|
||||||
//
|
//
|
||||||
// Created by 高原 on 2021/2/20.
|
// Created by BradGao on 2021/2/20.
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
import OSLog
|
||||||
|
import MastodonSDK
|
||||||
|
|
||||||
class PickServerViewController: UIViewController {
|
final class PickServerViewController: UIViewController, NeedsDependency {
|
||||||
let titleLabel: UILabel = {
|
|
||||||
let label = UILabel()
|
private var disposeBag = Set<AnyCancellable>()
|
||||||
label.font = .boldSystemFont(ofSize: 34)
|
|
||||||
label.textColor = Asset.Colors.Label.primary.color
|
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||||
label.text = L10n.Scene.ServerPicker.title
|
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||||
label.adjustsFontForContentSizeCategory = true
|
|
||||||
label.translatesAutoresizingMaskIntoConstraints = false
|
var viewModel: PickServerViewModel!
|
||||||
label.numberOfLines = 0
|
|
||||||
return label
|
private var isAuthenticating = CurrentValueSubject<Bool, Never>(false)
|
||||||
|
|
||||||
|
private var expandServerDomainSet = Set<String>()
|
||||||
|
|
||||||
|
enum Section: CaseIterable {
|
||||||
|
case title
|
||||||
|
case categories
|
||||||
|
case search
|
||||||
|
case serverList
|
||||||
|
}
|
||||||
|
|
||||||
|
let tableView: UITableView = {
|
||||||
|
let tableView = ControlContainableTableView()
|
||||||
|
tableView.register(PickServerTitleCell.self, forCellReuseIdentifier: String(describing: PickServerTitleCell.self))
|
||||||
|
tableView.register(PickServerCategoriesCell.self, forCellReuseIdentifier: String(describing: PickServerCategoriesCell.self))
|
||||||
|
tableView.register(PickServerSearchCell.self, forCellReuseIdentifier: String(describing: PickServerSearchCell.self))
|
||||||
|
tableView.register(PickServerCell.self, forCellReuseIdentifier: String(describing: PickServerCell.self))
|
||||||
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.backgroundColor = .clear
|
||||||
|
tableView.keyboardDismissMode = .onDrag
|
||||||
|
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
return tableView
|
||||||
|
}()
|
||||||
|
|
||||||
|
let nextStepButton: PrimaryActionButton = {
|
||||||
|
let button = PrimaryActionButton(type: .system)
|
||||||
|
button.setTitle(L10n.Common.Controls.Actions.signUp, for: .normal)
|
||||||
|
button.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return button
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension PickServerViewController {
|
||||||
|
|
||||||
|
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||||
|
return .darkContent
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
view.backgroundColor = Asset.Colors.Background.onboardingBackground.color
|
||||||
|
|
||||||
|
view.addSubview(nextStepButton)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
nextStepButton.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: 12),
|
||||||
|
view.readableContentGuide.trailingAnchor.constraint(equalTo: nextStepButton.trailingAnchor, constant: 12),
|
||||||
|
view.bottomAnchor.constraint(equalTo: nextStepButton.bottomAnchor, constant: 34),
|
||||||
|
])
|
||||||
|
|
||||||
|
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)
|
||||||
|
])
|
||||||
|
|
||||||
|
switch viewModel.mode {
|
||||||
|
case .signIn:
|
||||||
|
nextStepButton.setTitle(L10n.Common.Controls.Actions.signIn, for: .normal)
|
||||||
|
case .signUp:
|
||||||
|
nextStepButton.setTitle(L10n.Common.Controls.Actions.continue, for: .normal)
|
||||||
|
}
|
||||||
|
nextStepButton.addTarget(self, action: #selector(nextStepButtonDidClicked(_:)), for: .touchUpInside)
|
||||||
|
|
||||||
|
// viewModel.tableView = tableView
|
||||||
|
tableView.delegate = self
|
||||||
|
tableView.dataSource = self
|
||||||
|
|
||||||
|
viewModel
|
||||||
|
.searchedServers
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { _ in
|
||||||
|
|
||||||
|
} receiveValue: { [weak self] servers in
|
||||||
|
self?.tableView.reloadSections(IndexSet(integer: 3), with: .automatic)
|
||||||
|
if let selectedServer = self?.viewModel.selectedServer.value, servers.contains(selectedServer) {
|
||||||
|
// Previously selected server is still in the list, do nothing
|
||||||
|
} else {
|
||||||
|
// Previously selected server is not in the updated list, reset the selectedServer's value
|
||||||
|
self?.viewModel.selectedServer.send(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
viewModel
|
||||||
|
.selectedServer
|
||||||
|
.map {
|
||||||
|
$0 != nil
|
||||||
|
}
|
||||||
|
.assign(to: \.isEnabled, on: nextStepButton)
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
viewModel.error
|
||||||
|
.compactMap { $0 }
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] error in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let alertController = UIAlertController(for: error, title: "Error", preferredStyle: .alert)
|
||||||
|
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
|
||||||
|
alertController.addAction(okAction)
|
||||||
|
self.coordinator.present(
|
||||||
|
scene: .alertController(alertController: alertController),
|
||||||
|
from: nil,
|
||||||
|
transition: .alertController(animated: true, completion: nil)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
viewModel
|
||||||
|
.authenticated
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.flatMap { [weak self] (domain, user) -> AnyPublisher<Result<Bool, Error>, Never> in
|
||||||
|
guard let self = self else { return Just(.success(false)).eraseToAnyPublisher() }
|
||||||
|
return self.context.authenticationService.activeMastodonUser(domain: domain, userID: user.id)
|
||||||
|
}
|
||||||
|
.sink { [weak self] result in
|
||||||
|
guard let self = self else { return }
|
||||||
|
switch result {
|
||||||
|
case .failure(let error):
|
||||||
|
assertionFailure(error.localizedDescription)
|
||||||
|
case .success(let isActived):
|
||||||
|
assert(isActived)
|
||||||
|
self.coordinator.setup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
isAuthenticating
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] loading in
|
||||||
|
if loading {
|
||||||
|
self?.nextStepButton.showLoading()
|
||||||
|
} else {
|
||||||
|
self?.nextStepButton.stopLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
viewModel.fetchAllServers()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
navigationController?.setNavigationBarHidden(false, animated: animated)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
private func nextStepButtonDidClicked(_ sender: UIButton) {
|
||||||
|
switch viewModel.mode {
|
||||||
|
case .signIn:
|
||||||
|
doSignIn()
|
||||||
|
case .signUp:
|
||||||
|
doSignUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func doSignIn() {
|
||||||
|
guard let server = viewModel.selectedServer.value else { return }
|
||||||
|
isAuthenticating.send(true)
|
||||||
|
context.apiService.createApplication(domain: server.domain)
|
||||||
|
.tryMap { response -> PickServerViewModel.AuthenticateInfo in
|
||||||
|
let application = response.value
|
||||||
|
guard let info = PickServerViewModel.AuthenticateInfo(domain: server.domain, application: application) else {
|
||||||
|
throw APIService.APIError.explicit(.badResponse)
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] completion in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.isAuthenticating.send(false)
|
||||||
|
|
||||||
|
switch completion {
|
||||||
|
case .failure(let error):
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign in fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||||
|
self.viewModel.error.send(error)
|
||||||
|
case .finished:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] info in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let mastodonPinBasedAuthenticationViewModel = MastodonPinBasedAuthenticationViewModel(authenticateURL: info.authorizeURL)
|
||||||
|
self.viewModel.authenticate(
|
||||||
|
info: info,
|
||||||
|
pinCodePublisher: mastodonPinBasedAuthenticationViewModel.pinCodePublisher
|
||||||
|
)
|
||||||
|
self.viewModel.mastodonPinBasedAuthenticationViewController = self.coordinator.present(
|
||||||
|
scene: .mastodonPinBasedAuthentication(viewModel: mastodonPinBasedAuthenticationViewModel),
|
||||||
|
from: nil,
|
||||||
|
transition: .modal(animated: true, completion: nil)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func doSignUp() {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
guard let server = viewModel.selectedServer.value else { return }
|
||||||
|
isAuthenticating.send(true)
|
||||||
|
|
||||||
|
context.apiService.instance(domain: server.domain)
|
||||||
|
.compactMap { [weak self] response -> AnyPublisher<PickServerViewModel.SignUpResponseFirst, Error>? in
|
||||||
|
guard let self = self else { return nil }
|
||||||
|
guard response.value.registrations != false else {
|
||||||
|
return Fail(error: AuthenticationViewModel.AuthenticationError.registrationClosed).eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
return self.context.apiService.createApplication(domain: server.domain)
|
||||||
|
.map { PickServerViewModel.SignUpResponseFirst(instance: response, application: $0) }
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
.switchToLatest()
|
||||||
|
.tryMap { response -> PickServerViewModel.SignUpResponseSecond in
|
||||||
|
let application = response.application.value
|
||||||
|
guard let authenticateInfo = AuthenticationViewModel.AuthenticateInfo(domain: server.domain, application: application) else {
|
||||||
|
throw APIService.APIError.explicit(.badResponse)
|
||||||
|
}
|
||||||
|
return PickServerViewModel.SignUpResponseSecond(instance: response.instance, authenticateInfo: authenticateInfo)
|
||||||
|
}
|
||||||
|
.compactMap { [weak self] response -> AnyPublisher<PickServerViewModel.SignUpResponseThird, Error>? in
|
||||||
|
guard let self = self else { return nil }
|
||||||
|
let instance = response.instance
|
||||||
|
let authenticateInfo = response.authenticateInfo
|
||||||
|
return self.context.apiService.applicationAccessToken(
|
||||||
|
domain: server.domain,
|
||||||
|
clientID: authenticateInfo.clientID,
|
||||||
|
clientSecret: authenticateInfo.clientSecret
|
||||||
|
)
|
||||||
|
.map { PickServerViewModel.SignUpResponseThird(instance: instance, authenticateInfo: authenticateInfo, applicationToken: $0) }
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
.switchToLatest()
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] completion in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.isAuthenticating.send(false)
|
||||||
|
|
||||||
|
switch completion {
|
||||||
|
case .failure(let error):
|
||||||
|
self.viewModel.error.send(error)
|
||||||
|
case .finished:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] response in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let mastodonRegisterViewModel = MastodonRegisterViewModel(
|
||||||
|
domain: server.domain,
|
||||||
|
authenticateInfo: response.authenticateInfo,
|
||||||
|
instance: response.instance.value,
|
||||||
|
applicationToken: response.applicationToken.value
|
||||||
|
)
|
||||||
|
self.coordinator.present(scene: .mastodonRegister(viewModel: mastodonRegisterViewModel), from: self, transition: .show)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PickServerViewController: UITableViewDelegate {
|
||||||
|
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||||
|
let category = Section.allCases[section]
|
||||||
|
switch category {
|
||||||
|
case .title:
|
||||||
|
return 20
|
||||||
|
case .categories:
|
||||||
|
// 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:
|
||||||
|
// Header with 1 height as the separator
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
|
||||||
|
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) {
|
||||||
|
tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
|
||||||
|
viewModel.selectedServer.send(viewModel.searchedServers.value[indexPath.row])
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: false)
|
||||||
|
viewModel.selectedServer.send(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PickServerViewController: 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 {
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PickServerViewController: 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
tableView.beginUpdates()
|
||||||
|
updates()
|
||||||
|
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 PickServerViewController: PickServerSearchCellDelegate {
|
||||||
|
func pickServerSearchCell(didChange searchText: String?) {
|
||||||
|
viewModel.searchText.send(searchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PickServerViewController: PickServerCategoriesDataSource, PickServerCategoriesDelegate {
|
||||||
|
func numberOfCategories() -> Int {
|
||||||
|
return viewModel.categories.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func category(at index: Int) -> PickServerViewModel.Category {
|
||||||
|
return viewModel.categories[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectedIndex() -> Int {
|
||||||
|
return viewModel.selectCategoryIndex.value
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickServerCategoriesCell(didSelect index: Int) {
|
||||||
|
return viewModel.selectCategoryIndex.send(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,341 @@
|
||||||
|
//
|
||||||
|
// PickServerViewModel.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/2/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import OSLog
|
||||||
|
import Combine
|
||||||
|
import MastodonSDK
|
||||||
|
import CoreDataStack
|
||||||
|
|
||||||
|
class PickServerViewModel: 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 "❓"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode: PickServerMode
|
||||||
|
let context: AppContext
|
||||||
|
|
||||||
|
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>([])
|
||||||
|
|
||||||
|
let selectedServer = CurrentValueSubject<Mastodon.Entity.Server?, Never>(nil)
|
||||||
|
let error = PassthroughSubject<Error, Never>()
|
||||||
|
let authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>()
|
||||||
|
|
||||||
|
private var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
weak var tableView: UITableView?
|
||||||
|
|
||||||
|
// private var expandServerDomainSet = Set<String>()
|
||||||
|
|
||||||
|
var mastodonPinBasedAuthenticationViewController: UIViewController?
|
||||||
|
|
||||||
|
init(context: AppContext, mode: PickServerMode) {
|
||||||
|
self.context = context
|
||||||
|
self.mode = mode
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
configure()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
.flatMap { [weak self] (selectCategoryIndex, searchText, allServers) -> AnyPublisher<Result<[Mastodon.Entity.Server], Error>, Never> in
|
||||||
|
guard let self = self else { return Just(Result.success([])).eraseToAnyPublisher() }
|
||||||
|
|
||||||
|
// 1. Search from the servers recorded in joinmastodon.org
|
||||||
|
let searchedServersFromAPI = self.searchServersFromAPI(category: self.categories[selectCategoryIndex], searchText: searchText, allServers: allServers)
|
||||||
|
if !searchedServersFromAPI.isEmpty {
|
||||||
|
// If found servers, just return
|
||||||
|
return Just(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([])
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private func searchServersFromAPI(category: Category, searchText: String?, allServers: [Mastodon.Entity.Server]) -> [Mastodon.Entity.Server] {
|
||||||
|
return allServers
|
||||||
|
// 1. Filter the category
|
||||||
|
.filter {
|
||||||
|
switch category {
|
||||||
|
case .all:
|
||||||
|
return true
|
||||||
|
case .some(let masCategory):
|
||||||
|
return $0.category.caseInsensitiveCompare(masCategory.category.rawValue) == .orderedSame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. Filter the searchText
|
||||||
|
.filter {
|
||||||
|
if let searchText = searchText, !searchText.isEmpty {
|
||||||
|
return $0.domain.lowercased().contains(searchText.lowercased())
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SignIn methods & structs
|
||||||
|
extension PickServerViewModel {
|
||||||
|
enum AuthenticationError: Error, LocalizedError {
|
||||||
|
case badCredentials
|
||||||
|
case registrationClosed
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .badCredentials: return "Bad Credentials"
|
||||||
|
case .registrationClosed: return "Registration Closed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var failureReason: String? {
|
||||||
|
switch self {
|
||||||
|
case .badCredentials: return "Credentials invalid."
|
||||||
|
case .registrationClosed: return "Server disallow registration."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var helpAnchor: String? {
|
||||||
|
switch self {
|
||||||
|
case .badCredentials: return "Please try again."
|
||||||
|
case .registrationClosed: return "Please try another domain."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AuthenticateInfo {
|
||||||
|
let domain: String
|
||||||
|
let clientID: String
|
||||||
|
let clientSecret: String
|
||||||
|
let authorizeURL: URL
|
||||||
|
|
||||||
|
init?(domain: String, application: Mastodon.Entity.Application) {
|
||||||
|
self.domain = domain
|
||||||
|
guard let clientID = application.clientID,
|
||||||
|
let clientSecret = application.clientSecret else { return nil }
|
||||||
|
self.clientID = clientID
|
||||||
|
self.clientSecret = clientSecret
|
||||||
|
self.authorizeURL = {
|
||||||
|
let query = Mastodon.API.OAuth.AuthorizeQuery(clientID: clientID)
|
||||||
|
let url = Mastodon.API.OAuth.authorizeURL(domain: domain, query: query)
|
||||||
|
return url
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authenticate(info: AuthenticateInfo, pinCodePublisher: PassthroughSubject<String, Never>) {
|
||||||
|
pinCodePublisher
|
||||||
|
.handleEvents(receiveOutput: { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
// self.isAuthenticating.value = true
|
||||||
|
self.mastodonPinBasedAuthenticationViewController?.dismiss(animated: true, completion: nil)
|
||||||
|
self.mastodonPinBasedAuthenticationViewController = nil
|
||||||
|
})
|
||||||
|
.compactMap { [weak self] code -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error>? in
|
||||||
|
guard let self = self else { return nil }
|
||||||
|
return self.context.apiService
|
||||||
|
.userAccessToken(
|
||||||
|
domain: info.domain,
|
||||||
|
clientID: info.clientID,
|
||||||
|
clientSecret: info.clientSecret,
|
||||||
|
code: code
|
||||||
|
)
|
||||||
|
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
|
||||||
|
let token = response.value
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign in success. Token: %s", ((#file as NSString).lastPathComponent), #line, #function, token.accessToken)
|
||||||
|
return Self.verifyAndSaveAuthentication(
|
||||||
|
context: self.context,
|
||||||
|
info: info,
|
||||||
|
userToken: token
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
.switchToLatest()
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] completion in
|
||||||
|
guard let self = self else { return }
|
||||||
|
switch completion {
|
||||||
|
case .failure(let error):
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: swap user access token swap fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||||
|
// self.isAuthenticating.value = false
|
||||||
|
self.error.send(error)
|
||||||
|
case .finished:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] response in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let account = response.value
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: user %s sign in success", ((#file as NSString).lastPathComponent), #line, #function, account.username)
|
||||||
|
|
||||||
|
self.authenticated.send((domain: info.domain, account: account))
|
||||||
|
}
|
||||||
|
.store(in: &self.disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func verifyAndSaveAuthentication(
|
||||||
|
context: AppContext,
|
||||||
|
info: AuthenticateInfo,
|
||||||
|
userToken: Mastodon.Entity.Token
|
||||||
|
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> {
|
||||||
|
let authorization = Mastodon.API.OAuth.Authorization(accessToken: userToken.accessToken)
|
||||||
|
let managedObjectContext = context.backgroundManagedObjectContext
|
||||||
|
|
||||||
|
return context.apiService.accountVerifyCredentials(
|
||||||
|
domain: info.domain,
|
||||||
|
authorization: authorization
|
||||||
|
)
|
||||||
|
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Account>, Error> in
|
||||||
|
let account = response.value
|
||||||
|
let mastodonUserRequest = MastodonUser.sortedFetchRequest
|
||||||
|
mastodonUserRequest.predicate = MastodonUser.predicate(domain: info.domain, id: account.id)
|
||||||
|
mastodonUserRequest.fetchLimit = 1
|
||||||
|
guard let mastodonUser = try? managedObjectContext.fetch(mastodonUserRequest).first else {
|
||||||
|
return Fail(error: AuthenticationError.badCredentials).eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
let property = MastodonAuthentication.Property(
|
||||||
|
domain: info.domain,
|
||||||
|
userID: mastodonUser.id,
|
||||||
|
username: mastodonUser.username,
|
||||||
|
appAccessToken: userToken.accessToken, // TODO: swap app token
|
||||||
|
userAccessToken: userToken.accessToken,
|
||||||
|
clientID: info.clientID,
|
||||||
|
clientSecret: info.clientSecret
|
||||||
|
)
|
||||||
|
return managedObjectContext.performChanges {
|
||||||
|
_ = APIService.CoreData.createOrMergeMastodonAuthentication(
|
||||||
|
into: managedObjectContext,
|
||||||
|
for: mastodonUser,
|
||||||
|
in: info.domain,
|
||||||
|
property: property,
|
||||||
|
networkDate: response.networkDate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.tryMap { result in
|
||||||
|
switch result {
|
||||||
|
case .failure(let error): throw error
|
||||||
|
case .success: return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SignUp methods & structs
|
||||||
|
extension PickServerViewModel {
|
||||||
|
struct SignUpResponseFirst {
|
||||||
|
let instance: Mastodon.Response.Content<Mastodon.Entity.Instance>
|
||||||
|
let application: Mastodon.Response.Content<Mastodon.Entity.Application>
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SignUpResponseSecond {
|
||||||
|
let instance: Mastodon.Response.Content<Mastodon.Entity.Instance>
|
||||||
|
let authenticateInfo: AuthenticationViewModel.AuthenticateInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SignUpResponseThird {
|
||||||
|
let instance: Mastodon.Response.Content<Mastodon.Entity.Instance>
|
||||||
|
let authenticateInfo: AuthenticationViewModel.AuthenticateInfo
|
||||||
|
let applicationToken: Mastodon.Response.Content<Mastodon.Entity.Token>
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,110 @@
|
||||||
|
//
|
||||||
|
// PickServerCategoriesCell.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/2/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import MastodonSDK
|
||||||
|
|
||||||
|
protocol PickServerCategoriesDataSource: class {
|
||||||
|
func numberOfCategories() -> Int
|
||||||
|
func category(at index: Int) -> PickServerViewModel.Category
|
||||||
|
func selectedIndex() -> Int
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol PickServerCategoriesDelegate: class {
|
||||||
|
func pickServerCategoriesCell(didSelect index: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
final class PickServerCategoriesCell: UITableViewCell {
|
||||||
|
|
||||||
|
weak var dataSource: PickServerCategoriesDataSource!
|
||||||
|
weak var delegate: PickServerCategoriesDelegate!
|
||||||
|
|
||||||
|
let collectionView: UICollectionView = {
|
||||||
|
let flowLayout = UICollectionViewFlowLayout()
|
||||||
|
flowLayout.scrollDirection = .horizontal
|
||||||
|
let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
|
||||||
|
view.backgroundColor = .clear
|
||||||
|
view.showsHorizontalScrollIndicator = false
|
||||||
|
view.register(PickServerCategoryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self))
|
||||||
|
view.showsVerticalScrollIndicator = false
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
_init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
_init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PickServerCategoriesCell {
|
||||||
|
|
||||||
|
private func _init() {
|
||||||
|
self.selectionStyle = .none
|
||||||
|
backgroundColor = .clear
|
||||||
|
|
||||||
|
contentView.addSubview(collectionView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||||
|
collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||||
|
collectionView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||||
|
collectionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||||
|
|
||||||
|
collectionView.heightAnchor.constraint(equalToConstant: 80),
|
||||||
|
])
|
||||||
|
|
||||||
|
collectionView.delegate = self
|
||||||
|
collectionView.dataSource = self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
|
||||||
|
return UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,355 @@
|
||||||
|
//
|
||||||
|
// PickServerCell.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/2/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import MastodonSDK
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
|
protocol PickServerCellDelegate: class {
|
||||||
|
func pickServerCell(modeChange server: Mastodon.Entity.Server, newMode: PickServerCell.Mode, updates: (() -> Void))
|
||||||
|
}
|
||||||
|
|
||||||
|
class PickServerCell: UITableViewCell {
|
||||||
|
|
||||||
|
weak var delegate: PickServerCellDelegate?
|
||||||
|
|
||||||
|
enum Mode {
|
||||||
|
case collapse
|
||||||
|
case expand
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bgView: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = Asset.Colors.lightWhite.color
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var domainLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
label.textColor = Asset.Colors.lightDarkGray.color
|
||||||
|
label.adjustsFontForContentSizeCategory = true
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var checkbox: UIImageView = {
|
||||||
|
let imageView = UIImageView()
|
||||||
|
imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body)
|
||||||
|
imageView.tintColor = Asset.Colors.lightSecondaryText.color
|
||||||
|
imageView.contentMode = .scaleAspectFill
|
||||||
|
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var descriptionLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .preferredFont(forTextStyle: .subheadline)
|
||||||
|
label.numberOfLines = 0
|
||||||
|
label.textColor = Asset.Colors.lightDarkGray.color
|
||||||
|
label.adjustsFontForContentSizeCategory = true
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var thumbImageView: UIImageView = {
|
||||||
|
let imageView = UIImageView()
|
||||||
|
imageView.clipsToBounds = true
|
||||||
|
imageView.contentMode = .scaleAspectFill
|
||||||
|
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var infoStackView: UIStackView = {
|
||||||
|
let stackView = UIStackView()
|
||||||
|
stackView.axis = .horizontal
|
||||||
|
stackView.alignment = .fill
|
||||||
|
stackView.distribution = .fillEqually
|
||||||
|
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return stackView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var expandBox: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = .clear
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var 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)
|
||||||
|
button.setTitleColor(Asset.Colors.lightBrandBlue.color, for: .normal)
|
||||||
|
button.titleLabel?.font = .preferredFont(forTextStyle: .footnote)
|
||||||
|
button.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return button
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var seperator: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = Asset.Colors.lightBackground.color
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var langValueLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.textColor = Asset.Colors.lightDarkGray.color
|
||||||
|
label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold))
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.adjustsFontForContentSizeCategory = true
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var usersValueLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.textColor = Asset.Colors.lightDarkGray.color
|
||||||
|
label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold))
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.adjustsFontForContentSizeCategory = true
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var categoryValueLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.textColor = Asset.Colors.lightDarkGray.color
|
||||||
|
label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold))
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.adjustsFontForContentSizeCategory = true
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var langTitleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.textColor = Asset.Colors.lightDarkGray.color
|
||||||
|
label.font = .preferredFont(forTextStyle: .caption2)
|
||||||
|
label.text = L10n.Scene.ServerPicker.Label.language
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.adjustsFontForContentSizeCategory = true
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var usersTitleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.textColor = Asset.Colors.lightDarkGray.color
|
||||||
|
label.font = .preferredFont(forTextStyle: .caption2)
|
||||||
|
label.text = L10n.Scene.ServerPicker.Label.users
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.adjustsFontForContentSizeCategory = true
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var categoryTitleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.textColor = Asset.Colors.lightDarkGray.color
|
||||||
|
label.font = .preferredFont(forTextStyle: .caption2)
|
||||||
|
label.text = L10n.Scene.ServerPicker.Label.category
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.adjustsFontForContentSizeCategory = true
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var collapseConstraints: [NSLayoutConstraint] = []
|
||||||
|
private var expandConstraints: [NSLayoutConstraint] = []
|
||||||
|
|
||||||
|
var mode: PickServerCell.Mode = .collapse {
|
||||||
|
didSet {
|
||||||
|
updateMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var server: Mastodon.Entity.Server? {
|
||||||
|
didSet {
|
||||||
|
updateServerInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
_init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
_init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Methods to configure appearance
|
||||||
|
extension PickServerCell {
|
||||||
|
private func _init() {
|
||||||
|
selectionStyle = .none
|
||||||
|
backgroundColor = .clear
|
||||||
|
|
||||||
|
contentView.addSubview(bgView)
|
||||||
|
contentView.addSubview(domainLabel)
|
||||||
|
contentView.addSubview(checkbox)
|
||||||
|
contentView.addSubview(descriptionLabel)
|
||||||
|
contentView.addSubview(seperator)
|
||||||
|
|
||||||
|
contentView.addSubview(expandButton)
|
||||||
|
|
||||||
|
// Always add the expandbox which contains elements only visible in expand mode
|
||||||
|
contentView.addSubview(expandBox)
|
||||||
|
expandBox.addSubview(thumbImageView)
|
||||||
|
expandBox.addSubview(infoStackView)
|
||||||
|
expandBox.isHidden = true
|
||||||
|
|
||||||
|
let verticalInfoStackViewLang = makeVerticalInfoStackView(arrangedView: langValueLabel, langTitleLabel)
|
||||||
|
let verticalInfoStackViewUsers = makeVerticalInfoStackView(arrangedView: usersValueLabel, usersTitleLabel)
|
||||||
|
let verticalInfoStackViewCategory = makeVerticalInfoStackView(arrangedView: categoryValueLabel, categoryTitleLabel)
|
||||||
|
infoStackView.addArrangedSubview(verticalInfoStackViewLang)
|
||||||
|
infoStackView.addArrangedSubview(verticalInfoStackViewUsers)
|
||||||
|
infoStackView.addArrangedSubview(verticalInfoStackViewCategory)
|
||||||
|
|
||||||
|
let expandButtonTopConstraintInCollapse = expandButton.topAnchor.constraint(equalTo: descriptionLabel.lastBaselineAnchor, constant: 12).priority(.required)
|
||||||
|
collapseConstraints.append(expandButtonTopConstraintInCollapse)
|
||||||
|
|
||||||
|
let expandButtonTopConstraintInExpand = expandButton.topAnchor.constraint(equalTo: expandBox.bottomAnchor, constant: 8).priority(.required)
|
||||||
|
expandConstraints.append(expandButtonTopConstraintInExpand)
|
||||||
|
|
||||||
|
// domainLabel.setContentHuggingPriority(.required - 1, for: .vertical)
|
||||||
|
// domainLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical)
|
||||||
|
// descriptionLabel.setContentHuggingPriority(.required - 2, for: .vertical)
|
||||||
|
// descriptionLabel.setContentCompressionResistancePriority(.required - 2, for: .vertical)
|
||||||
|
domainLabel.setContentHuggingPriority(.required, for: .vertical)
|
||||||
|
domainLabel.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||||
|
descriptionLabel.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
||||||
|
descriptionLabel.setContentCompressionResistancePriority(.defaultHigh, for: .vertical)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
// Set background view
|
||||||
|
bgView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
|
||||||
|
bgView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||||
|
contentView.readableContentGuide.trailingAnchor.constraint(equalTo: bgView.trailingAnchor),
|
||||||
|
contentView.bottomAnchor.constraint(equalTo: bgView.bottomAnchor, constant: 1),
|
||||||
|
|
||||||
|
// Set bottom separator
|
||||||
|
seperator.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
|
||||||
|
contentView.readableContentGuide.trailingAnchor.constraint(equalTo: seperator.trailingAnchor),
|
||||||
|
contentView.bottomAnchor.constraint(equalTo: seperator.bottomAnchor),
|
||||||
|
seperator.heightAnchor.constraint(equalToConstant: 1),
|
||||||
|
|
||||||
|
domainLabel.leadingAnchor.constraint(equalTo: bgView.leadingAnchor, constant: 16),
|
||||||
|
domainLabel.topAnchor.constraint(equalTo: bgView.topAnchor, constant: 16),
|
||||||
|
|
||||||
|
checkbox.widthAnchor.constraint(equalToConstant: 23),
|
||||||
|
checkbox.heightAnchor.constraint(equalToConstant: 22),
|
||||||
|
bgView.trailingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 16),
|
||||||
|
checkbox.leadingAnchor.constraint(equalTo: domainLabel.trailingAnchor, constant: 16),
|
||||||
|
checkbox.centerYAnchor.constraint(equalTo: domainLabel.centerYAnchor),
|
||||||
|
|
||||||
|
descriptionLabel.leadingAnchor.constraint(equalTo: bgView.leadingAnchor, constant: 16),
|
||||||
|
descriptionLabel.topAnchor.constraint(equalTo: domainLabel.firstBaselineAnchor, constant: 8).priority(.required),
|
||||||
|
bgView.trailingAnchor.constraint(equalTo: descriptionLabel.trailingAnchor, constant: 16),
|
||||||
|
|
||||||
|
// Set expandBox constraints
|
||||||
|
expandBox.leadingAnchor.constraint(equalTo: bgView.leadingAnchor, constant: 16),
|
||||||
|
bgView.trailingAnchor.constraint(equalTo: expandBox.trailingAnchor, constant: 16),
|
||||||
|
expandBox.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: 8),
|
||||||
|
expandBox.bottomAnchor.constraint(equalTo: infoStackView.bottomAnchor).priority(.defaultHigh),
|
||||||
|
|
||||||
|
thumbImageView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor),
|
||||||
|
expandBox.trailingAnchor.constraint(equalTo: thumbImageView.trailingAnchor),
|
||||||
|
thumbImageView.topAnchor.constraint(equalTo: expandBox.topAnchor).priority(.defaultHigh),
|
||||||
|
thumbImageView.heightAnchor.constraint(equalTo: thumbImageView.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),
|
||||||
|
|
||||||
|
expandButton.leadingAnchor.constraint(equalTo: bgView.leadingAnchor, constant: 16),
|
||||||
|
bgView.trailingAnchor.constraint(equalTo: expandButton.trailingAnchor, constant: 16),
|
||||||
|
bgView.bottomAnchor.constraint(equalTo: expandButton.bottomAnchor, constant: 8),
|
||||||
|
])
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate(collapseConstraints)
|
||||||
|
|
||||||
|
expandButton.addTarget(self, action: #selector(expandButtonDidClicked(_:)), for: .touchUpInside)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeVerticalInfoStackView(arrangedView: UIView...) -> UIStackView {
|
||||||
|
let stackView = UIStackView()
|
||||||
|
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
stackView.axis = .vertical
|
||||||
|
stackView.alignment = .center
|
||||||
|
stackView.distribution = .equalCentering
|
||||||
|
stackView.spacing = 2
|
||||||
|
arrangedView.forEach { stackView.addArrangedSubview($0) }
|
||||||
|
return stackView
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateMode() {
|
||||||
|
switch mode {
|
||||||
|
case .collapse:
|
||||||
|
expandBox.isHidden = true
|
||||||
|
expandButton.isSelected = false
|
||||||
|
NSLayoutConstraint.deactivate(expandConstraints)
|
||||||
|
NSLayoutConstraint.activate(collapseConstraints)
|
||||||
|
case .expand:
|
||||||
|
expandBox.isHidden = false
|
||||||
|
expandButton.isSelected = true
|
||||||
|
NSLayoutConstraint.activate(expandConstraints)
|
||||||
|
NSLayoutConstraint.deactivate(collapseConstraints)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setSelected(_ selected: Bool, animated: Bool) {
|
||||||
|
super.setSelected(selected, animated: animated)
|
||||||
|
if selected {
|
||||||
|
checkbox.image = UIImage(systemName: "checkmark.circle.fill")
|
||||||
|
} else {
|
||||||
|
checkbox.image = UIImage(systemName: "circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
private func expandButtonDidClicked(_ sender: UIButton) {
|
||||||
|
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 = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,103 @@
|
||||||
|
//
|
||||||
|
// PickServerSearchCell.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/2/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
protocol PickServerSearchCellDelegate: class {
|
||||||
|
func pickServerSearchCell(didChange searchText: String?)
|
||||||
|
}
|
||||||
|
|
||||||
|
class PickServerSearchCell: UITableViewCell {
|
||||||
|
|
||||||
|
weak var delegate: PickServerSearchCellDelegate?
|
||||||
|
|
||||||
|
private var bgView: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = Asset.Colors.lightWhite.color
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.layer.maskedCorners = [
|
||||||
|
.layerMinXMinYCorner,
|
||||||
|
.layerMaxXMinYCorner
|
||||||
|
]
|
||||||
|
view.layer.cornerCurve = .continuous
|
||||||
|
view.layer.cornerRadius = 10
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var textFieldBgView: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = Asset.Colors.lightBackground.color.withAlphaComponent(0.6)
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.layer.masksToBounds = true
|
||||||
|
view.layer.cornerRadius = 6
|
||||||
|
view.layer.cornerCurve = .continuous
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var searchTextField: UITextField = {
|
||||||
|
let textField = UITextField()
|
||||||
|
textField.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
textField.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
textField.tintColor = Asset.Colors.lightDarkGray.color
|
||||||
|
textField.textColor = Asset.Colors.lightDarkGray.color
|
||||||
|
textField.adjustsFontForContentSizeCategory = true
|
||||||
|
textField.attributedPlaceholder =
|
||||||
|
NSAttributedString(string: L10n.Scene.ServerPicker.Input.placeholder,
|
||||||
|
attributes: [.font: UIFont.preferredFont(forTextStyle: .headline),
|
||||||
|
.foregroundColor: Asset.Colors.lightSecondaryText.color.withAlphaComponent(0.6)])
|
||||||
|
textField.clearButtonMode = .whileEditing
|
||||||
|
textField.autocapitalizationType = .none
|
||||||
|
textField.autocorrectionType = .no
|
||||||
|
return textField
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
_init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
_init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PickServerSearchCell {
|
||||||
|
private func _init() {
|
||||||
|
self.selectionStyle = .none
|
||||||
|
backgroundColor = .clear
|
||||||
|
|
||||||
|
searchTextField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged)
|
||||||
|
|
||||||
|
contentView.addSubview(bgView)
|
||||||
|
contentView.addSubview(textFieldBgView)
|
||||||
|
contentView.addSubview(searchTextField)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
bgView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
|
||||||
|
bgView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||||
|
bgView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
|
||||||
|
bgView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||||
|
|
||||||
|
textFieldBgView.leadingAnchor.constraint(equalTo: bgView.leadingAnchor, constant: 14),
|
||||||
|
textFieldBgView.topAnchor.constraint(equalTo: bgView.topAnchor, constant: 12),
|
||||||
|
bgView.trailingAnchor.constraint(equalTo: textFieldBgView.trailingAnchor, constant: 14),
|
||||||
|
bgView.bottomAnchor.constraint(equalTo: textFieldBgView.bottomAnchor, constant: 13),
|
||||||
|
|
||||||
|
searchTextField.leadingAnchor.constraint(equalTo: textFieldBgView.leadingAnchor, constant: 11),
|
||||||
|
searchTextField.topAnchor.constraint(equalTo: textFieldBgView.topAnchor, constant: 4),
|
||||||
|
textFieldBgView.trailingAnchor.constraint(equalTo: searchTextField.trailingAnchor, constant: 11),
|
||||||
|
textFieldBgView.bottomAnchor.constraint(equalTo: searchTextField.bottomAnchor, constant: 4),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PickServerSearchCell {
|
||||||
|
@objc func textFieldDidChange(_ textField: UITextField) {
|
||||||
|
delegate?.pickServerSearchCell(didChange: textField.text)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
//
|
||||||
|
// PickServerTitleCell.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/2/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class PickServerTitleCell: UITableViewCell {
|
||||||
|
|
||||||
|
let titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: UIFont.boldSystemFont(ofSize: 34))
|
||||||
|
label.textColor = Asset.Colors.Label.primary.color
|
||||||
|
label.text = L10n.Scene.ServerPicker.title
|
||||||
|
label.adjustsFontForContentSizeCategory = true
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
label.numberOfLines = 0
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
_init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
_init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PickServerTitleCell {
|
||||||
|
|
||||||
|
private func _init() {
|
||||||
|
self.selectionStyle = .none
|
||||||
|
backgroundColor = .clear
|
||||||
|
|
||||||
|
contentView.addSubview(titleLabel)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
titleLabel.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
|
||||||
|
contentView.readableContentGuide.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
|
||||||
|
titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||||
|
titleLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,99 @@
|
||||||
|
//
|
||||||
|
// PickServerCategoryView.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/2/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import MastodonSDK
|
||||||
|
|
||||||
|
class PickServerCategoryView: UIView {
|
||||||
|
var category: PickServerViewModel.Category? {
|
||||||
|
didSet {
|
||||||
|
updateCategory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var selected: Bool = false {
|
||||||
|
didSet {
|
||||||
|
updateSelectStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bgShadowView: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
var bgView: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.layer.masksToBounds = true
|
||||||
|
view.layer.cornerRadius = 30
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
var titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
configure()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
configure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,12 +2,24 @@
|
||||||
// PrimaryActionButton.swift
|
// PrimaryActionButton.swift
|
||||||
// Mastodon
|
// Mastodon
|
||||||
//
|
//
|
||||||
// Created by 高原 on 2021/2/20.
|
// Created by BradGao on 2021/2/20.
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class PrimaryActionButton: UIButton {
|
class PrimaryActionButton: UIButton {
|
||||||
|
|
||||||
|
var isLoading: Bool = false
|
||||||
|
|
||||||
|
lazy var activityIndicator: UIActivityIndicatorView = {
|
||||||
|
let indicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
indicator.hidesWhenStopped = true
|
||||||
|
indicator.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return indicator
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var originalButtonTitle: String?
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
_init()
|
_init()
|
||||||
|
@ -17,13 +29,39 @@ class PrimaryActionButton: UIButton {
|
||||||
super.init(coder: coder)
|
super.init(coder: coder)
|
||||||
_init()
|
_init()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func showLoading() {
|
||||||
|
guard !isLoading else { return }
|
||||||
|
isEnabled = false
|
||||||
|
isLoading = true
|
||||||
|
originalButtonTitle = title(for: .disabled)
|
||||||
|
self.setTitle("", for: .disabled)
|
||||||
|
|
||||||
|
addSubview(activityIndicator)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
activityIndicator.centerXAnchor.constraint(equalTo: self.centerXAnchor),
|
||||||
|
activityIndicator.centerYAnchor.constraint(equalTo: self.centerYAnchor),
|
||||||
|
])
|
||||||
|
activityIndicator.startAnimating()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopLoading() {
|
||||||
|
guard isLoading else { return }
|
||||||
|
isLoading = false
|
||||||
|
if activityIndicator.superview == self {
|
||||||
|
activityIndicator.removeFromSuperview()
|
||||||
|
}
|
||||||
|
isEnabled = true
|
||||||
|
self.setTitle(originalButtonTitle, for: .disabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PrimaryActionButton {
|
extension PrimaryActionButton {
|
||||||
private func _init() {
|
private func _init() {
|
||||||
titleLabel?.font = .preferredFont(forTextStyle: .headline)
|
titleLabel?.font = .preferredFont(forTextStyle: .headline)
|
||||||
setTitleColor(Asset.Colors.lightWhite.color, for: .normal)
|
setTitleColor(Asset.Colors.lightWhite.color, for: .normal)
|
||||||
backgroundColor = Asset.Colors.lightBrandBlue.color
|
setBackgroundImage(UIImage.placeholder(color: Asset.Colors.lightBrandBlue.color), for: .normal)
|
||||||
|
setBackgroundImage(UIImage.placeholder(color: Asset.Colors.lightDisabled.color), for: .disabled)
|
||||||
applyCornerRadius(radius: 10)
|
applyCornerRadius(radius: 10)
|
||||||
setInsets(forContentPadding: UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0), imageTitlePadding: 0)
|
setInsets(forContentPadding: UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0), imageTitlePadding: 0)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
// WelcomeViewController.swift
|
// WelcomeViewController.swift
|
||||||
// Mastodon
|
// Mastodon
|
||||||
//
|
//
|
||||||
// Created by 高原 on 2021/2/20.
|
// Created by BradGao on 2021/2/20.
|
||||||
//
|
//
|
||||||
|
|
||||||
import os.log
|
import os.log
|
||||||
|
@ -13,15 +13,6 @@ final class WelcomeViewController: UIViewController, NeedsDependency {
|
||||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
lazy var authenticationViewController: AuthenticationViewController = {
|
|
||||||
let authenticationViewController = AuthenticationViewController()
|
|
||||||
authenticationViewController.context = context
|
|
||||||
authenticationViewController.coordinator = coordinator
|
|
||||||
return authenticationViewController
|
|
||||||
}()
|
|
||||||
#endif
|
|
||||||
|
|
||||||
let logoImageView: UIImageView = {
|
let logoImageView: UIImageView = {
|
||||||
let imageView = UIImageView(image: Asset.welcomeLogo.image)
|
let imageView = UIImageView(image: Asset.welcomeLogo.image)
|
||||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
@ -65,6 +56,10 @@ extension WelcomeViewController {
|
||||||
|
|
||||||
overrideUserInterfaceStyle = .light
|
overrideUserInterfaceStyle = .light
|
||||||
view.backgroundColor = Asset.Colors.Background.onboardingBackground.color
|
view.backgroundColor = Asset.Colors.Background.onboardingBackground.color
|
||||||
|
navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default)
|
||||||
|
navigationController?.navigationBar.shadowImage = UIImage()
|
||||||
|
navigationController?.navigationBar.isTranslucent = true
|
||||||
|
navigationController?.view.backgroundColor = .clear
|
||||||
|
|
||||||
view.addSubview(logoImageView)
|
view.addSubview(logoImageView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
|
@ -93,8 +88,8 @@ extension WelcomeViewController {
|
||||||
signInButton.topAnchor.constraint(equalTo: signUpButton.bottomAnchor, constant: 5)
|
signInButton.topAnchor.constraint(equalTo: signUpButton.bottomAnchor, constant: 5)
|
||||||
])
|
])
|
||||||
|
|
||||||
signInButton.addTarget(self, action: #selector(WelcomeViewController.signInButtonPressed(_:)), for: .touchUpInside)
|
signUpButton.addTarget(self, action: #selector(signUpButtonDidClicked(_:)), for: .touchUpInside)
|
||||||
signUpButton.addTarget(self, action: #selector(WelcomeViewController.signUpButtonPressed(_:)), for: .touchUpInside)
|
signInButton.addTarget(self, action: #selector(signInButtonDidClicked(_:)), for: .touchUpInside)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
@ -105,20 +100,13 @@ extension WelcomeViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension WelcomeViewController {
|
extension WelcomeViewController {
|
||||||
|
@objc
|
||||||
@objc private func signInButtonPressed(_ sender: UIButton) {
|
private func signUpButtonDidClicked(_ sender: UIButton) {
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
coordinator.present(scene: .pickServer(viewMode: PickServerViewModel(context: context, mode: .signUp)), from: self, transition: .show)
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
authenticationViewController.viewModel = AuthenticationViewModel(context: context, coordinator: coordinator, isAuthenticationExist: true)
|
|
||||||
authenticationViewController.viewModel.domain.value = "pawoo.net"
|
|
||||||
let _ = authenticationViewController.view // trigger view load
|
|
||||||
authenticationViewController.signInButton.sendActions(for: .touchUpInside)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func signUpButtonPressed(_ sender: UIButton) {
|
@objc
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
private func signInButtonDidClicked(_ sender: UIButton) {
|
||||||
|
coordinator.present(scene: .pickServer(viewMode: PickServerViewModel(context: context, mode: .signIn)), from: self, transition: .show)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ import Foundation
|
||||||
|
|
||||||
extension Mastodon.Entity {
|
extension Mastodon.Entity {
|
||||||
|
|
||||||
public struct Server: Codable {
|
public struct Server: Codable, Equatable {
|
||||||
public let domain: String
|
public let domain: String
|
||||||
public let version: String
|
public let version: String
|
||||||
public let description: String
|
public let description: String
|
||||||
|
@ -37,6 +37,25 @@ extension Mastodon.Entity {
|
||||||
case language
|
case language
|
||||||
case category
|
case category
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public init(instance: Instance) {
|
||||||
|
self.domain = instance.uri
|
||||||
|
self.version = instance.version ?? ""
|
||||||
|
self.description = instance.shortDescription ?? instance.description
|
||||||
|
self.language = instance.languages?.first ?? ""
|
||||||
|
self.languages = instance.languages ?? []
|
||||||
|
self.region = "Unknown" // TODO: how to handle properties not in an instance
|
||||||
|
self.categories = []
|
||||||
|
self.category = "Unknown"
|
||||||
|
self.proxiedThumbnail = instance.thumbnail
|
||||||
|
self.totalUsers = instance.statistics?.userCount ?? 0
|
||||||
|
self.lastWeekUsers = 0
|
||||||
|
self.approvalRequired = instance.approvalRequired ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func == (lhs: Self, rhs: Self) -> Bool {
|
||||||
|
return lhs.domain.caseInsensitiveCompare(rhs.domain) == .orderedSame
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue