feat: [WIP] add search result list for scopes searching
This commit is contained in:
parent
58c720c7df
commit
8d4752d71f
|
@ -431,7 +431,8 @@
|
|||
}
|
||||
},
|
||||
"search": {
|
||||
"searchBar": {
|
||||
"title": "Search",
|
||||
"search_bar": {
|
||||
"placeholder": "Search hashtags and users",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
|
@ -452,7 +453,8 @@
|
|||
"segment": {
|
||||
"all": "All",
|
||||
"people": "People",
|
||||
"hashtags": "Hashtags"
|
||||
"hashtags": "Hashtags",
|
||||
"posts": "Posts"
|
||||
},
|
||||
"recent_search": "Recent searches",
|
||||
"clear": "Clear"
|
||||
|
@ -518,13 +520,13 @@
|
|||
"using_default_browser": "Using default browser open link"
|
||||
},
|
||||
"boringzone": {
|
||||
"title": "The Boring zone",
|
||||
"title": "The Boring Zone",
|
||||
"account_settings": "Account settings",
|
||||
"terms": "Terms of Service",
|
||||
"privacy": "Privacy Policy"
|
||||
},
|
||||
"spicyzone": {
|
||||
"title": "The spicy zone",
|
||||
"title": "The Spicy Zone",
|
||||
"clear": "Clear Media Cache",
|
||||
"signout": "Sign Out"
|
||||
}
|
||||
|
|
|
@ -38,7 +38,6 @@
|
|||
2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; };
|
||||
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; };
|
||||
2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198648261C0B8500F0B013 /* SearchResultSection.swift */; };
|
||||
2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */; };
|
||||
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; };
|
||||
2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7F25F5F45E00143C56 /* UIImage.swift */; };
|
||||
2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; };
|
||||
|
@ -141,7 +140,6 @@
|
|||
2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */; };
|
||||
2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */; };
|
||||
2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; };
|
||||
2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */; };
|
||||
2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */; };
|
||||
5B24BBDA262DB14800A9381B /* ReportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */; };
|
||||
5B24BBDB262DB14800A9381B /* ReportViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */; };
|
||||
|
@ -272,6 +270,13 @@
|
|||
DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */; };
|
||||
DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */; };
|
||||
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */; };
|
||||
DB4F0963269ED06300D62E92 /* SearchResultViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0962269ED06300D62E92 /* SearchResultViewController.swift */; };
|
||||
DB4F0966269ED52200D62E92 /* SearchResultViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0965269ED52200D62E92 /* SearchResultViewModel.swift */; };
|
||||
DB4F0968269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */; };
|
||||
DB4F096A269EDAD200D62E92 /* SearchResultViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0969269EDAD200D62E92 /* SearchResultViewModel+State.swift */; };
|
||||
DB4F096C269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F096B269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift */; };
|
||||
DB4FFC2B269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4FFC29269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift */; };
|
||||
DB4FFC2C269EC39600D62E92 /* SearchDetailTransitionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4FFC2A269EC39600D62E92 /* SearchDetailTransitionController.swift */; };
|
||||
DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */; };
|
||||
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; };
|
||||
DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */; };
|
||||
|
@ -512,6 +517,9 @@
|
|||
DBE54ABF2636C889004E7C0B /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B23263684C600ACB481 /* UserDefaults.swift */; };
|
||||
DBE54AC62636C89F004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; };
|
||||
DBE54ACC2636C8FD004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; };
|
||||
DBF1D24E269DAF5D00C1C08A /* SearchDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */; };
|
||||
DBF1D251269DB01200C1C08A /* SearchHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF1D250269DB01200C1C08A /* SearchHistoryViewController.swift */; };
|
||||
DBF1D257269DBAC600C1C08A /* SearchDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF1D256269DBAC600C1C08A /* SearchDetailViewModel.swift */; };
|
||||
DBF7A0FC26830C33004176A2 /* FPSIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = DBF7A0FB26830C33004176A2 /* FPSIndicator */; };
|
||||
DBF8AE16263293E400C9C23C /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF8AE15263293E400C9C23C /* NotificationService.swift */; };
|
||||
DBF8AE1A263293E400C9C23C /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = DBF8AE13263293E400C9C23C /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
|
@ -653,7 +661,6 @@
|
|||
2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; };
|
||||
2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = "<group>"; };
|
||||
2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = "<group>"; };
|
||||
2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewModel+LoadOldestState.swift"; sourceTree = "<group>"; };
|
||||
2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = "<group>"; };
|
||||
2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = "<group>"; };
|
||||
2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = "<group>"; };
|
||||
|
@ -753,7 +760,6 @@
|
|||
2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Favorite.swift"; sourceTree = "<group>"; };
|
||||
2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectObserver.swift; sourceTree = "<group>"; };
|
||||
2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectContextObjectsDidChange.swift; sourceTree = "<group>"; };
|
||||
2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Searching.swift"; sourceTree = "<group>"; };
|
||||
2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchingTableViewCell.swift; sourceTree = "<group>"; };
|
||||
2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
374AA339A20E0FAC75BCDA6D /* Pods_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
|
@ -911,6 +917,13 @@
|
|||
DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel+LoadState.swift"; sourceTree = "<group>"; };
|
||||
DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CustomEmoji.swift"; sourceTree = "<group>"; };
|
||||
DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlayerContainerView+MediaTypeIndicotorView.swift"; sourceTree = "<group>"; };
|
||||
DB4F0962269ED06300D62E92 /* SearchResultViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultViewController.swift; sourceTree = "<group>"; };
|
||||
DB4F0965269ED52200D62E92 /* SearchResultViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultViewModel.swift; sourceTree = "<group>"; };
|
||||
DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryTableHeaderView.swift; sourceTree = "<group>"; };
|
||||
DB4F0969269EDAD200D62E92 /* SearchResultViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewModel+State.swift"; sourceTree = "<group>"; };
|
||||
DB4F096B269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewController+StatusProvider.swift"; sourceTree = "<group>"; };
|
||||
DB4FFC29269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchToSearchDetailViewControllerAnimatedTransitioning.swift; sourceTree = "<group>"; };
|
||||
DB4FFC2A269EC39600D62E92 /* SearchDetailTransitionController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchDetailTransitionController.swift; sourceTree = "<group>"; };
|
||||
DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = "<group>"; };
|
||||
DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = "<group>"; };
|
||||
DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = "<group>"; };
|
||||
|
@ -1137,6 +1150,9 @@
|
|||
DBE3CE0C261D767100430CC6 /* FavoriteViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewController+Provider.swift"; sourceTree = "<group>"; };
|
||||
DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerAspect.swift; sourceTree = "<group>"; };
|
||||
DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPreference.swift; sourceTree = "<group>"; };
|
||||
DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchDetailViewController.swift; sourceTree = "<group>"; };
|
||||
DBF1D250269DB01200C1C08A /* SearchHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryViewController.swift; sourceTree = "<group>"; };
|
||||
DBF1D256269DBAC600C1C08A /* SearchDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchDetailViewModel.swift; sourceTree = "<group>"; };
|
||||
DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = "<group>"; };
|
||||
DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = "<group>"; };
|
||||
DBF8AE13263293E400C9C23C /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
|
@ -1988,6 +2004,27 @@
|
|||
path = EmojiService;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB4F0964269ED06700D62E92 /* SearchResult */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2DFAD5212616F8E300F9EE7C /* TableViewCell */,
|
||||
DB4F0962269ED06300D62E92 /* SearchResultViewController.swift */,
|
||||
DB4F096B269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift */,
|
||||
DB4F0965269ED52200D62E92 /* SearchResultViewModel.swift */,
|
||||
DB4F0969269EDAD200D62E92 /* SearchResultViewModel+State.swift */,
|
||||
);
|
||||
path = SearchResult;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB4FFC2D269EC39C00D62E92 /* SearchDetail */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB4FFC2A269EC39600D62E92 /* SearchDetailTransitionController.swift */,
|
||||
DB4FFC29269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift */,
|
||||
);
|
||||
path = SearchDetail;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB5086CB25CC0DB400C2C187 /* Preference */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -2041,6 +2078,7 @@
|
|||
children = (
|
||||
DB6180E226391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift */,
|
||||
DB6180E726391B580018D199 /* MediaPreview */,
|
||||
DB4FFC2D269EC39C00D62E92 /* SearchDetail */,
|
||||
);
|
||||
path = Transition;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2385,15 +2423,10 @@
|
|||
DB9D6BEE25E4F5370051B173 /* Search */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2DFAD5212616F8E300F9EE7C /* TableViewCell */,
|
||||
2DE0FAC62615F5D200CDF649 /* View */,
|
||||
DB9D6BE825E4F5340051B173 /* SearchViewController.swift */,
|
||||
2D34D9CA261489930081BFC0 /* SearchViewController+Recommend.swift */,
|
||||
2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */,
|
||||
5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */,
|
||||
2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */,
|
||||
2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */,
|
||||
2D34D9E026149C550081BFC0 /* CollectionViewCell */,
|
||||
DBF1D253269DB02C00C1C08A /* Search */,
|
||||
DBF1D24F269DAF6100C1C08A /* SearchDetail */,
|
||||
DB4F0964269ED06700D62E92 /* SearchResult */,
|
||||
DBF1D252269DB01700C1C08A /* SearchHistory */,
|
||||
);
|
||||
path = Search;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2657,6 +2690,37 @@
|
|||
path = Favorite;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBF1D24F269DAF6100C1C08A /* SearchDetail */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */,
|
||||
DBF1D256269DBAC600C1C08A /* SearchDetailViewModel.swift */,
|
||||
);
|
||||
path = SearchDetail;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBF1D252269DB01700C1C08A /* SearchHistory */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DBF1D250269DB01200C1C08A /* SearchHistoryViewController.swift */,
|
||||
DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */,
|
||||
);
|
||||
path = SearchHistory;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBF1D253269DB02C00C1C08A /* Search */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D34D9E026149C550081BFC0 /* CollectionViewCell */,
|
||||
2DE0FAC62615F5D200CDF649 /* View */,
|
||||
DB9D6BE825E4F5340051B173 /* SearchViewController.swift */,
|
||||
2D34D9CA261489930081BFC0 /* SearchViewController+Recommend.swift */,
|
||||
5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */,
|
||||
2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */,
|
||||
);
|
||||
path = Search;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBF8AE14263293E400C9C23C /* NotificationService */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -3188,6 +3252,7 @@
|
|||
DBA94440265D137600C537E1 /* Mastodon+Entity+Field.swift in Sources */,
|
||||
DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */,
|
||||
DBBF1DC7265251D400E5B703 /* AutoCompleteViewModel+State.swift in Sources */,
|
||||
DB4FFC2B269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift in Sources */,
|
||||
DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */,
|
||||
2D7631B325C159F700929FB9 /* Item.swift in Sources */,
|
||||
5DF1054125F886D400D6C0D4 /* VideoPlaybackService.swift in Sources */,
|
||||
|
@ -3216,6 +3281,7 @@
|
|||
DBD376AA2692EA4F007FEC24 /* Theme.swift in Sources */,
|
||||
0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */,
|
||||
DB443CD42694627B00159B29 /* AppearanceView.swift in Sources */,
|
||||
DBF1D24E269DAF5D00C1C08A /* SearchDetailViewController.swift in Sources */,
|
||||
DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */,
|
||||
DB92CF7225E7BB98002C1017 /* PollOptionTableViewCell.swift in Sources */,
|
||||
DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */,
|
||||
|
@ -3250,7 +3316,7 @@
|
|||
2D8FCA082637EABB00137F46 /* APIService+FollowRequest.swift in Sources */,
|
||||
2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */,
|
||||
2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */,
|
||||
2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */,
|
||||
DBF1D251269DB01200C1C08A /* SearchHistoryViewController.swift in Sources */,
|
||||
2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */,
|
||||
DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */,
|
||||
DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */,
|
||||
|
@ -3360,6 +3426,7 @@
|
|||
5B24BBDB262DB14800A9381B /* ReportViewModel+Diffable.swift in Sources */,
|
||||
DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */,
|
||||
2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */,
|
||||
DB4F0968269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift in Sources */,
|
||||
0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */,
|
||||
5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */,
|
||||
DBAEDE5C267A058D00D25FF5 /* BlurhashImageCacheService.swift in Sources */,
|
||||
|
@ -3411,11 +3478,13 @@
|
|||
DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */,
|
||||
DB023295267F0AB800031745 /* ASMetaEditableTextNode.swift in Sources */,
|
||||
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
|
||||
DB4F096C269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift in Sources */,
|
||||
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */,
|
||||
DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */,
|
||||
DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */,
|
||||
DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */,
|
||||
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */,
|
||||
DBF1D257269DBAC600C1C08A /* SearchDetailViewModel.swift in Sources */,
|
||||
DB03F7F52689B782007B274C /* ComposeTableView.swift in Sources */,
|
||||
2D24E11D2626D8B100A59D4F /* NotificationStatusTableViewCell.swift in Sources */,
|
||||
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */,
|
||||
|
@ -3435,6 +3504,7 @@
|
|||
DB6180E626391B550018D199 /* MediaPreviewTransitionController.swift in Sources */,
|
||||
5BB04FDB262EA3070043BFF6 /* ReportHeaderView.swift in Sources */,
|
||||
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */,
|
||||
DB4F0963269ED06300D62E92 /* SearchResultViewController.swift in Sources */,
|
||||
DBBF1DC5265251C300E5B703 /* AutoCompleteViewModel+Diffable.swift in Sources */,
|
||||
DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */,
|
||||
0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */,
|
||||
|
@ -3445,6 +3515,7 @@
|
|||
DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */,
|
||||
DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */,
|
||||
2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */,
|
||||
DB4FFC2C269EC39600D62E92 /* SearchDetailTransitionController.swift in Sources */,
|
||||
DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */,
|
||||
DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */,
|
||||
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
|
||||
|
@ -3525,11 +3596,11 @@
|
|||
DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */,
|
||||
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */,
|
||||
DBD376B2269302A4007FEC24 /* UITableViewCell.swift in Sources */,
|
||||
DB4F0966269ED52200D62E92 /* SearchResultViewModel.swift in Sources */,
|
||||
DBAC6499267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift in Sources */,
|
||||
2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */,
|
||||
DBBF1DBF2652401B00E5B703 /* AutoCompleteViewModel.swift in Sources */,
|
||||
DB6180FA26391F2E0018D199 /* MediaPreviewViewModel.swift in Sources */,
|
||||
2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */,
|
||||
0F202227261411BB000C64BF /* HashtagTimelineViewController+Provider.swift in Sources */,
|
||||
2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */,
|
||||
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */,
|
||||
|
@ -3573,6 +3644,7 @@
|
|||
DB1EE7B2267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift in Sources */,
|
||||
DB03F7F026899097007B274C /* ComposeStatusContentTableViewCell.swift in Sources */,
|
||||
5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */,
|
||||
DB4F096A269EDAD200D62E92 /* SearchResultViewModel+State.swift in Sources */,
|
||||
5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */,
|
||||
DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */,
|
||||
DB938F25262438D600E5B6C1 /* ThreadViewController+Provider.swift in Sources */,
|
||||
|
|
|
@ -52,7 +52,10 @@ extension SceneCoordinator {
|
|||
// ASDK
|
||||
case asyncHome
|
||||
#endif
|
||||
|
||||
|
||||
// search
|
||||
case searchDetail(viewModel: SearchDetailViewModel)
|
||||
|
||||
// compose
|
||||
case compose(viewModel: ComposeViewModel)
|
||||
|
||||
|
@ -254,6 +257,10 @@ private extension SceneCoordinator {
|
|||
let _viewController = AsyncHomeTimelineViewController()
|
||||
viewController = _viewController
|
||||
#endif
|
||||
case .searchDetail(let viewModel):
|
||||
let _viewController = SearchDetailViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .compose(let viewModel):
|
||||
let _viewController = ComposeViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
|
|
|
@ -11,12 +11,11 @@ import MastodonSDK
|
|||
|
||||
enum SearchResultItem {
|
||||
case hashtag(tag: Mastodon.Entity.Tag)
|
||||
|
||||
case account(account: Mastodon.Entity.Account)
|
||||
|
||||
case accountObjectID(accountObjectID: NSManagedObjectID)
|
||||
|
||||
case hashtagObjectID(hashtagObjectID: NSManagedObjectID)
|
||||
case status(statusObjectID: NSManagedObjectID, attribute: Item.StatusAttribute)
|
||||
|
||||
case bottomLoader
|
||||
}
|
||||
|
@ -28,12 +27,14 @@ extension SearchResultItem: Equatable {
|
|||
return tagLeft == tagRight
|
||||
case (.account(let accountLeft), .account(let accountRight)):
|
||||
return accountLeft == accountRight
|
||||
case (.accountObjectID(let idLeft), .accountObjectID(let idRight)):
|
||||
return idLeft == idRight
|
||||
case (.hashtagObjectID(let idLeft), .hashtagObjectID(let idRight)):
|
||||
return idLeft == idRight
|
||||
case (.status(let idLeft, _), .status(let idRight, _)):
|
||||
return idLeft == idRight
|
||||
case (.bottomLoader, .bottomLoader):
|
||||
return true
|
||||
case (.accountObjectID(let idLeft),.accountObjectID(let idRight)):
|
||||
return idLeft == idRight
|
||||
case (.hashtagObjectID(let idLeft),.hashtagObjectID(let idRight)):
|
||||
return idLeft == idRight
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
@ -44,15 +45,29 @@ extension SearchResultItem: Hashable {
|
|||
func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case .account(let account):
|
||||
hasher.combine(account)
|
||||
hasher.combine(String(describing: SearchResultItem.account.self))
|
||||
hasher.combine(account.id)
|
||||
case .hashtag(let tag):
|
||||
hasher.combine(tag)
|
||||
hasher.combine(String(describing: SearchResultItem.hashtag.self))
|
||||
hasher.combine(tag.name)
|
||||
case .accountObjectID(let id):
|
||||
hasher.combine(id)
|
||||
case .hashtagObjectID(let id):
|
||||
hasher.combine(id)
|
||||
case .status(let id, _):
|
||||
hasher.combine(id)
|
||||
case .bottomLoader:
|
||||
hasher.combine(String(describing: SearchResultItem.bottomLoader.self))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchResultItem {
|
||||
var sortKey: String? {
|
||||
switch self {
|
||||
case .account(let account): return account.displayName.lowercased()
|
||||
case .hashtag(let hashtag): return hashtag.name.lowercased()
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,42 +12,62 @@ import CoreData
|
|||
import CoreDataStack
|
||||
|
||||
enum SearchResultSection: Equatable, Hashable {
|
||||
case account
|
||||
case hashtag
|
||||
case mixed
|
||||
case bottomLoader
|
||||
case main
|
||||
}
|
||||
|
||||
extension SearchResultSection {
|
||||
static func tableViewDiffableDataSource(
|
||||
for tableView: UITableView,
|
||||
dependency: NeedsDependency
|
||||
dependency: NeedsDependency,
|
||||
statusTableViewCellDelegate: StatusTableViewCellDelegate
|
||||
) -> UITableViewDiffableDataSource<SearchResultSection, SearchResultItem> {
|
||||
UITableViewDiffableDataSource(tableView: tableView) { (tableView, indexPath, result) -> UITableViewCell? in
|
||||
switch result {
|
||||
UITableViewDiffableDataSource(tableView: tableView) { [
|
||||
weak statusTableViewCellDelegate
|
||||
] tableView, indexPath, item -> UITableViewCell? in
|
||||
switch item {
|
||||
case .account(let account):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchResultTableViewCell.self), for: indexPath) as! SearchResultTableViewCell
|
||||
cell.config(with: account)
|
||||
return cell
|
||||
case .hashtag(let tag):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchResultTableViewCell.self), for: indexPath) as! SearchResultTableViewCell
|
||||
cell.config(with: tag)
|
||||
return cell
|
||||
case .hashtagObjectID(let hashtagObjectID):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
|
||||
let tag = dependency.context.managedObjectContext.object(with: hashtagObjectID) as! Tag
|
||||
cell.config(with: tag)
|
||||
return cell
|
||||
case .accountObjectID(let accountObjectID):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
|
||||
let user = dependency.context.managedObjectContext.object(with: accountObjectID) as! MastodonUser
|
||||
cell.config(with: user)
|
||||
// case .hashtagObjectID(let hashtagObjectID):
|
||||
// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
|
||||
// let tag = dependency.context.managedObjectContext.object(with: hashtagObjectID) as! Tag
|
||||
// cell.config(with: tag)
|
||||
// return cell
|
||||
// case .accountObjectID(let accountObjectID):
|
||||
// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
|
||||
// let user = dependency.context.managedObjectContext.object(with: accountObjectID) as! MastodonUser
|
||||
// cell.config(with: user)
|
||||
// return cell
|
||||
case .status(let statusObjectID, let attribute):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
|
||||
if let status = try? dependency.context.managedObjectContext.existingObject(with: statusObjectID) as? Status {
|
||||
let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value
|
||||
let requestUserID = activeMastodonAuthenticationBox?.userID ?? ""
|
||||
StatusSection.configure(
|
||||
cell: cell,
|
||||
tableView: tableView,
|
||||
timelineContext: .search,
|
||||
dependency: dependency,
|
||||
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
|
||||
status: status,
|
||||
requestUserID: requestUserID,
|
||||
statusItemAttribute: attribute
|
||||
)
|
||||
}
|
||||
cell.delegate = statusTableViewCellDelegate
|
||||
return cell
|
||||
case .bottomLoader:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell
|
||||
cell.startAnimating()
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
fatalError()
|
||||
} // end switch
|
||||
} // end UITableViewDiffableDataSource
|
||||
} // end func
|
||||
}
|
||||
|
|
|
@ -228,6 +228,7 @@ extension StatusSection {
|
|||
case favorite
|
||||
case hashtag
|
||||
case report
|
||||
case search
|
||||
|
||||
var filterContext: Mastodon.Entity.Filter.Context? {
|
||||
switch self {
|
||||
|
|
|
@ -788,6 +788,8 @@ internal enum L10n {
|
|||
}
|
||||
}
|
||||
internal enum Search {
|
||||
/// Search
|
||||
internal static let title = L10n.tr("Localizable", "Scene.Search.Title")
|
||||
internal enum Recommend {
|
||||
/// See All
|
||||
internal static let buttonText = L10n.tr("Localizable", "Scene.Search.Recommend.ButtonText")
|
||||
|
@ -810,11 +812,11 @@ internal enum L10n {
|
|||
internal static let title = L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.Title")
|
||||
}
|
||||
}
|
||||
internal enum Searchbar {
|
||||
internal enum SearchBar {
|
||||
/// Cancel
|
||||
internal static let cancel = L10n.tr("Localizable", "Scene.Search.Searchbar.Cancel")
|
||||
internal static let cancel = L10n.tr("Localizable", "Scene.Search.SearchBar.Cancel")
|
||||
/// Search hashtags and users
|
||||
internal static let placeholder = L10n.tr("Localizable", "Scene.Search.Searchbar.Placeholder")
|
||||
internal static let placeholder = L10n.tr("Localizable", "Scene.Search.SearchBar.Placeholder")
|
||||
}
|
||||
internal enum Searching {
|
||||
/// Clear
|
||||
|
@ -828,6 +830,8 @@ internal enum L10n {
|
|||
internal static let hashtags = L10n.tr("Localizable", "Scene.Search.Searching.Segment.Hashtags")
|
||||
/// People
|
||||
internal static let people = L10n.tr("Localizable", "Scene.Search.Searching.Segment.People")
|
||||
/// Posts
|
||||
internal static let posts = L10n.tr("Localizable", "Scene.Search.Searching.Segment.Posts")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -948,7 +952,7 @@ internal enum L10n {
|
|||
internal static let privacy = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Privacy")
|
||||
/// Terms of Service
|
||||
internal static let terms = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Terms")
|
||||
/// The Boring zone
|
||||
/// The Boring Zone
|
||||
internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Title")
|
||||
}
|
||||
internal enum Notifications {
|
||||
|
@ -986,7 +990,7 @@ internal enum L10n {
|
|||
internal static let clear = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Clear")
|
||||
/// Sign Out
|
||||
internal static let signout = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Signout")
|
||||
/// The spicy zone
|
||||
/// The Spicy Zone
|
||||
internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Title")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -274,13 +274,15 @@ tap the link to confirm your account.";
|
|||
"Scene.Search.Recommend.HashTag.Description" = "Hashtags that are getting quite a bit of attention among people you follow";
|
||||
"Scene.Search.Recommend.HashTag.PeopleTalking" = "%@ people are talking";
|
||||
"Scene.Search.Recommend.HashTag.Title" = "Trending in your timeline";
|
||||
"Scene.Search.Searchbar.Cancel" = "Cancel";
|
||||
"Scene.Search.Searchbar.Placeholder" = "Search hashtags and users";
|
||||
"Scene.Search.SearchBar.Cancel" = "Cancel";
|
||||
"Scene.Search.SearchBar.Placeholder" = "Search hashtags and users";
|
||||
"Scene.Search.Searching.Clear" = "Clear";
|
||||
"Scene.Search.Searching.RecentSearch" = "Recent searches";
|
||||
"Scene.Search.Searching.Segment.All" = "All";
|
||||
"Scene.Search.Searching.Segment.Hashtags" = "Hashtags";
|
||||
"Scene.Search.Searching.Segment.People" = "People";
|
||||
"Scene.Search.Searching.Segment.Posts" = "Posts";
|
||||
"Scene.Search.Title" = "Search";
|
||||
"Scene.ServerPicker.Button.Category.Academia" = "academia";
|
||||
"Scene.ServerPicker.Button.Category.Activism" = "activism";
|
||||
"Scene.ServerPicker.Button.Category.All" = "All";
|
||||
|
@ -323,7 +325,7 @@ any server.";
|
|||
"Scene.Settings.Section.Boringzone.AccountSettings" = "Account settings";
|
||||
"Scene.Settings.Section.Boringzone.Privacy" = "Privacy Policy";
|
||||
"Scene.Settings.Section.Boringzone.Terms" = "Terms of Service";
|
||||
"Scene.Settings.Section.Boringzone.Title" = "The Boring zone";
|
||||
"Scene.Settings.Section.Boringzone.Title" = "The Boring Zone";
|
||||
"Scene.Settings.Section.Notifications.Boosts" = "Reblogs my post";
|
||||
"Scene.Settings.Section.Notifications.Favorites" = "Favorites my post";
|
||||
"Scene.Settings.Section.Notifications.Follows" = "Follows me";
|
||||
|
@ -338,7 +340,7 @@ any server.";
|
|||
"Scene.Settings.Section.Preference.UsingDefaultBrowser" = "Using default browser open link";
|
||||
"Scene.Settings.Section.Spicyzone.Clear" = "Clear Media Cache";
|
||||
"Scene.Settings.Section.Spicyzone.Signout" = "Sign Out";
|
||||
"Scene.Settings.Section.Spicyzone.Title" = "The spicy zone";
|
||||
"Scene.Settings.Section.Spicyzone.Title" = "The Spicy Zone";
|
||||
"Scene.Settings.Title" = "Settings";
|
||||
"Scene.SuggestionAccount.FollowExplain" = "When you follow someone, you’ll see their posts in your home feed.";
|
||||
"Scene.SuggestionAccount.Title" = "Find People to Follow";
|
||||
|
|
|
@ -274,13 +274,15 @@ tap the link to confirm your account.";
|
|||
"Scene.Search.Recommend.HashTag.Description" = "Hashtags that are getting quite a bit of attention among people you follow";
|
||||
"Scene.Search.Recommend.HashTag.PeopleTalking" = "%@ people are talking";
|
||||
"Scene.Search.Recommend.HashTag.Title" = "Trending in your timeline";
|
||||
"Scene.Search.Searchbar.Cancel" = "Cancel";
|
||||
"Scene.Search.Searchbar.Placeholder" = "Search hashtags and users";
|
||||
"Scene.Search.SearchBar.Cancel" = "Cancel";
|
||||
"Scene.Search.SearchBar.Placeholder" = "Search hashtags and users";
|
||||
"Scene.Search.Searching.Clear" = "Clear";
|
||||
"Scene.Search.Searching.RecentSearch" = "Recent searches";
|
||||
"Scene.Search.Searching.Segment.All" = "All";
|
||||
"Scene.Search.Searching.Segment.Hashtags" = "Hashtags";
|
||||
"Scene.Search.Searching.Segment.People" = "People";
|
||||
"Scene.Search.Searching.Segment.Posts" = "Posts";
|
||||
"Scene.Search.Title" = "Search";
|
||||
"Scene.ServerPicker.Button.Category.Academia" = "academia";
|
||||
"Scene.ServerPicker.Button.Category.Activism" = "activism";
|
||||
"Scene.ServerPicker.Button.Category.All" = "All";
|
||||
|
@ -323,7 +325,7 @@ any server.";
|
|||
"Scene.Settings.Section.Boringzone.AccountSettings" = "Account settings";
|
||||
"Scene.Settings.Section.Boringzone.Privacy" = "Privacy Policy";
|
||||
"Scene.Settings.Section.Boringzone.Terms" = "Terms of Service";
|
||||
"Scene.Settings.Section.Boringzone.Title" = "The Boring zone";
|
||||
"Scene.Settings.Section.Boringzone.Title" = "The Boring Zone";
|
||||
"Scene.Settings.Section.Notifications.Boosts" = "Reblogs my post";
|
||||
"Scene.Settings.Section.Notifications.Favorites" = "Favorites my post";
|
||||
"Scene.Settings.Section.Notifications.Follows" = "Follows me";
|
||||
|
@ -338,7 +340,7 @@ any server.";
|
|||
"Scene.Settings.Section.Preference.UsingDefaultBrowser" = "Using default browser open link";
|
||||
"Scene.Settings.Section.Spicyzone.Clear" = "Clear Media Cache";
|
||||
"Scene.Settings.Section.Spicyzone.Signout" = "Sign Out";
|
||||
"Scene.Settings.Section.Spicyzone.Title" = "The spicy zone";
|
||||
"Scene.Settings.Section.Spicyzone.Title" = "The Spicy Zone";
|
||||
"Scene.Settings.Title" = "Settings";
|
||||
"Scene.SuggestionAccount.FollowExplain" = "When you follow someone, you’ll see their posts in your home feed.";
|
||||
"Scene.SuggestionAccount.Title" = "Find People to Follow";
|
||||
|
|
|
@ -242,8 +242,6 @@ extension ComposeViewController {
|
|||
}
|
||||
return margin
|
||||
}()
|
||||
|
||||
// update keyboard background color
|
||||
|
||||
guard isShow, state == .dock else {
|
||||
self.tableView.contentInset.bottom = extraMargin
|
||||
|
@ -591,19 +589,6 @@ extension ComposeViewController {
|
|||
|
||||
private func updateKeyboardBackground(isKeyboardDisplay: Bool) {
|
||||
composeToolbarBackgroundView.backgroundColor = Asset.Scene.Compose.toolbarBackground.color
|
||||
|
||||
// Deprecated: not works for new Dark Mode color
|
||||
// guard isKeyboardDisplay else {
|
||||
// composeToolbarBackgroundView.backgroundColor = Asset.Scene.Compose.toolbarBackground.color
|
||||
// return
|
||||
// }
|
||||
// composeToolbarBackgroundView.backgroundColor = UIColor(dynamicProvider: { traitCollection -> UIColor in
|
||||
// // avoid elevated color
|
||||
// switch traitCollection.userInterfaceStyle {
|
||||
// case .light: return .white
|
||||
// default: return .black
|
||||
// }
|
||||
// })
|
||||
}
|
||||
|
||||
private func setupBackgroundColor(theme: Theme) {
|
||||
|
|
|
@ -101,14 +101,13 @@ extension MainTabBarController {
|
|||
delegate = self
|
||||
|
||||
view.backgroundColor = ThemeService.shared.currentTheme.value.systemBackgroundColor
|
||||
// ThemeService.shared.currentTheme
|
||||
// .receive(on: RunLoop.main)
|
||||
// .sink { [weak self] theme in
|
||||
// guard let self = self else { return }
|
||||
// // fix tab bar not update color issue
|
||||
// self.tabBar.backgroundColor = theme.tabBarBackgroundColor
|
||||
// }
|
||||
// .store(in: &disposeBag)
|
||||
ThemeService.shared.currentTheme
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] theme in
|
||||
guard let self = self else { return }
|
||||
self.view.backgroundColor = theme.tabBarBackgroundColor
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
let tabs = Tab.allCases
|
||||
let viewControllers: [UIViewController] = tabs.map { tab in
|
||||
|
@ -189,6 +188,10 @@ extension MainTabBarController {
|
|||
self.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
#if DEBUG
|
||||
// selectedIndex = 1
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -62,12 +62,19 @@ extension SearchViewController: UICollectionViewDelegate {
|
|||
case self.accountsCollectionView:
|
||||
guard let diffableDataSource = viewModel.accountDiffableDataSource else { return }
|
||||
guard let accountObjectID = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
let user = context.managedObjectContext.object(with: accountObjectID) as! MastodonUser
|
||||
viewModel.accountCollectionViewItemDidSelected(mastodonUser: user, from: self)
|
||||
let mastodonUser = context.managedObjectContext.object(with: accountObjectID) as! MastodonUser
|
||||
let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser)
|
||||
DispatchQueue.main.async {
|
||||
self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show)
|
||||
}
|
||||
case self.hashtagCollectionView:
|
||||
guard let diffableDataSource = viewModel.hashtagDiffableDataSource else { return }
|
||||
guard let hashtag = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
viewModel.hashtagCollectionViewItemDidSelected(hashtag: hashtag, from: self)
|
||||
let (tagInCoreData, _) = APIService.CoreData.createOrMergeTag(into: context.managedObjectContext, entity: hashtag)
|
||||
let viewModel = HashtagTimelineViewModel(context: context, hashtag: tagInCoreData.name)
|
||||
DispatchQueue.main.async {
|
||||
self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: self, transition: .show)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
|
@ -0,0 +1,198 @@
|
|||
//
|
||||
// SearchViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/31.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Combine
|
||||
import GameplayKit
|
||||
import MastodonSDK
|
||||
import UIKit
|
||||
|
||||
final class SearchViewController: UIViewController, NeedsDependency {
|
||||
|
||||
let logger = Logger(subsystem: "Search", category: "UI")
|
||||
|
||||
public static var hashtagCardHeight: CGFloat {
|
||||
get {
|
||||
if UIScreen.main.bounds.size.height > 736 {
|
||||
return 186
|
||||
}
|
||||
return 130
|
||||
}
|
||||
}
|
||||
|
||||
public static var hashtagPeopleTalkingLabelTop: CGFloat {
|
||||
get {
|
||||
if UIScreen.main.bounds.size.height > 736 {
|
||||
return 18
|
||||
}
|
||||
return 6
|
||||
}
|
||||
}
|
||||
public static let accountCardHeight = 202
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var searchDetailTransitionController = SearchDetailTransitionController()
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
private(set) lazy var viewModel = SearchViewModel(context: context, coordinator: coordinator)
|
||||
|
||||
// recommend
|
||||
let scrollView: UIScrollView = {
|
||||
let scrollView = UIScrollView()
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.alwaysBounceVertical = true
|
||||
scrollView.clipsToBounds = false
|
||||
return scrollView
|
||||
}()
|
||||
|
||||
let stackView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .vertical
|
||||
stackView.distribution = .fill
|
||||
return stackView
|
||||
}()
|
||||
|
||||
let hashtagCollectionView: UICollectionView = {
|
||||
let flowLayout = UICollectionViewFlowLayout()
|
||||
flowLayout.scrollDirection = .horizontal
|
||||
let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
|
||||
view.backgroundColor = .clear
|
||||
view.showsHorizontalScrollIndicator = false
|
||||
view.showsVerticalScrollIndicator = false
|
||||
view.layer.masksToBounds = false
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
return view
|
||||
}()
|
||||
|
||||
let accountsCollectionView: UICollectionView = {
|
||||
let flowLayout = UICollectionViewFlowLayout()
|
||||
flowLayout.scrollDirection = .horizontal
|
||||
let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
|
||||
view.backgroundColor = .clear
|
||||
view.showsHorizontalScrollIndicator = false
|
||||
view.showsVerticalScrollIndicator = false
|
||||
view.layer.masksToBounds = false
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
return view
|
||||
}()
|
||||
|
||||
}
|
||||
|
||||
extension SearchViewController {
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
|
||||
ThemeService.shared.currentTheme
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] theme in
|
||||
guard let self = self else { return }
|
||||
self.setupBackgroundColor(theme: theme)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
title = L10n.Scene.Search.title
|
||||
|
||||
setupSearchBar()
|
||||
setupScrollView()
|
||||
setupHashTagCollectionView()
|
||||
setupAccountsCollectionView()
|
||||
setupDataSource()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
viewModel.viewDidAppeared.send()
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchViewController {
|
||||
private func setupBackgroundColor(theme: Theme) {
|
||||
view.backgroundColor = theme.systemGroupedBackgroundColor
|
||||
}
|
||||
|
||||
private func setupSearchBar() {
|
||||
let searchBar = UISearchBar()
|
||||
searchBar.placeholder = L10n.Scene.Search.SearchBar.placeholder
|
||||
searchBar.delegate = self
|
||||
navigationItem.titleView = searchBar
|
||||
}
|
||||
|
||||
private func setupScrollView() {
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// scrollView
|
||||
view.addSubview(scrollView)
|
||||
NSLayoutConstraint.activate([
|
||||
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
view.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor),
|
||||
scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
scrollView.frameLayoutGuide.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor),
|
||||
])
|
||||
|
||||
// stack view
|
||||
scrollView.addSubview(stackView)
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
|
||||
stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
|
||||
stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
|
||||
stackView.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor),
|
||||
scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: stackView.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
private func setupDataSource() {
|
||||
viewModel.hashtagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashtagCollectionView)
|
||||
viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView, delegate: self, managedObjectContext: context.managedObjectContext)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UISearchBarDelegate
|
||||
extension SearchViewController: UISearchBarDelegate {
|
||||
func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool {
|
||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
||||
// push to search detail
|
||||
let searchDetailViewModel = SearchDetailViewModel()
|
||||
searchDetailViewModel.needsBecomeFirstResponder = true
|
||||
self.navigationController?.delegate = self.searchDetailTransitionController
|
||||
self.coordinator.present(scene: .searchDetail(viewModel: searchDetailViewModel), from: self, transition: .customPush)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK - UISearchControllerDelegate
|
||||
extension SearchViewController: UISearchControllerDelegate {
|
||||
func willDismissSearchController(_ searchController: UISearchController) {
|
||||
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
|
||||
searchController.isActive = true
|
||||
}
|
||||
func didPresentSearchController(_ searchController: UISearchController) {
|
||||
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(SwiftUI) && DEBUG
|
||||
import SwiftUI
|
||||
|
||||
struct SearchViewController_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
UIViewControllerPreview {
|
||||
let viewController = SearchViewController()
|
||||
return viewController
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 800))
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -38,25 +38,8 @@ final class SearchViewModel: NSObject {
|
|||
|
||||
var hashtagDiffableDataSource: UICollectionViewDiffableDataSource<RecommendHashTagSection, Mastodon.Entity.Tag>?
|
||||
var accountDiffableDataSource: UICollectionViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID>?
|
||||
var searchResultDiffableDataSource: UITableViewDiffableDataSource<SearchResultSection, SearchResultItem>?
|
||||
|
||||
let statusFetchedResultsController: StatusFetchedResultsController
|
||||
|
||||
// bottom loader
|
||||
private(set) lazy var loadoldestStateMachine: GKStateMachine = {
|
||||
// exclude timeline middle fetcher state
|
||||
let stateMachine = GKStateMachine(states: [
|
||||
LoadOldestState.Initial(viewModel: self),
|
||||
LoadOldestState.Loading(viewModel: self),
|
||||
LoadOldestState.Fail(viewModel: self),
|
||||
LoadOldestState.Idle(viewModel: self),
|
||||
LoadOldestState.NoMore(viewModel: self),
|
||||
])
|
||||
stateMachine.enter(LoadOldestState.Initial.self)
|
||||
return stateMachine
|
||||
}()
|
||||
|
||||
lazy var loadOldestStateMachinePublisher = CurrentValueSubject<LoadOldestState?, Never>(nil)
|
||||
|
||||
init(context: AppContext, coordinator: SceneCoordinator) {
|
||||
self.coordinator = coordinator
|
||||
|
@ -134,38 +117,6 @@ final class SearchViewModel: NSObject {
|
|||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest3(
|
||||
isSearching,
|
||||
searchText,
|
||||
searchScope
|
||||
)
|
||||
.filter { isSearching, _, _ in
|
||||
isSearching
|
||||
}
|
||||
.sink { [weak self] _, text, scope in
|
||||
guard text.isEmpty else { return }
|
||||
guard let self = self else { return }
|
||||
guard let searchHistories = self.fetchSearchHistory() else { return }
|
||||
guard let dataSource = self.searchResultDiffableDataSource else { return }
|
||||
var snapshot = NSDiffableDataSourceSnapshot<SearchResultSection, SearchResultItem>()
|
||||
snapshot.appendSections([.mixed])
|
||||
|
||||
searchHistories.forEach { searchHistory in
|
||||
let containsAccount = scope == Mastodon.API.V2.Search.SearchType.accounts || scope == Mastodon.API.V2.Search.SearchType.default
|
||||
let containsHashTag = scope == Mastodon.API.V2.Search.SearchType.hashtags || scope == Mastodon.API.V2.Search.SearchType.default
|
||||
if let mastodonUser = searchHistory.account, containsAccount {
|
||||
let item = SearchResultItem.accountObjectID(accountObjectID: mastodonUser.objectID)
|
||||
snapshot.appendItems([item], toSection: .mixed)
|
||||
}
|
||||
if let tag = searchHistory.hashtag, containsHashTag {
|
||||
let item = SearchResultItem.hashtagObjectID(hashtagObjectID: tag.objectID)
|
||||
snapshot.appendItems([item], toSection: .mixed)
|
||||
}
|
||||
}
|
||||
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest(
|
||||
context.authenticationService.activeMastodonAuthenticationBox,
|
||||
|
@ -232,32 +183,6 @@ final class SearchViewModel: NSObject {
|
|||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
searchResult
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] searchResult in
|
||||
guard let self = self else { return }
|
||||
guard let dataSource = self.searchResultDiffableDataSource else { return }
|
||||
var snapshot = NSDiffableDataSourceSnapshot<SearchResultSection, SearchResultItem>()
|
||||
if let accounts = searchResult?.accounts {
|
||||
snapshot.appendSections([.account])
|
||||
let items = accounts.compactMap { SearchResultItem.account(account: $0) }
|
||||
snapshot.appendItems(items, toSection: .account)
|
||||
if self.searchScope.value == Mastodon.API.V2.Search.SearchType.accounts, !items.isEmpty {
|
||||
snapshot.appendItems([.bottomLoader], toSection: .account)
|
||||
}
|
||||
}
|
||||
if let tags = searchResult?.hashtags {
|
||||
snapshot.appendSections([.hashtag])
|
||||
let items = tags.compactMap { SearchResultItem.hashtag(tag: $0) }
|
||||
snapshot.appendItems(items, toSection: .hashtag)
|
||||
if self.searchScope.value == Mastodon.API.V2.Search.SearchType.hashtags, !items.isEmpty {
|
||||
snapshot.appendItems([.bottomLoader], toSection: .hashtag)
|
||||
}
|
||||
}
|
||||
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
func receiveAccounts(ids: [Mastodon.Entity.Account.ID]) {
|
||||
|
@ -295,21 +220,6 @@ final class SearchViewModel: NSObject {
|
|||
snapshot.appendItems(self.recommendAccounts, toSection: .main)
|
||||
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
}
|
||||
|
||||
func accountCollectionViewItemDidSelected(mastodonUser: MastodonUser, from: UIViewController) {
|
||||
let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser)
|
||||
DispatchQueue.main.async {
|
||||
self.coordinator.present(scene: .profile(viewModel: viewModel), from: from, transition: .show)
|
||||
}
|
||||
}
|
||||
|
||||
func hashtagCollectionViewItemDidSelected(hashtag: Mastodon.Entity.Tag, from: UIViewController) {
|
||||
let (tagInCoreData, _) = APIService.CoreData.createOrMergeTag(into: context.managedObjectContext, entity: hashtag)
|
||||
let viewModel = HashtagTimelineViewModel(context: context, hashtag: tagInCoreData.name)
|
||||
DispatchQueue.main.async {
|
||||
self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: from, transition: .show)
|
||||
}
|
||||
}
|
||||
|
||||
func searchResultItemDidSelected(item: SearchResultItem, from: UIViewController) {
|
||||
let searchHistories = fetchSearchHistory()
|
|
@ -0,0 +1,231 @@
|
|||
//
|
||||
// SearchDetailViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-13.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import Pageboy
|
||||
|
||||
final class SearchDetailViewController: PageboyViewController, NeedsDependency {
|
||||
|
||||
let logger = Logger(subsystem: "SearchDetail", category: "UI")
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var viewModel: SearchDetailViewModel!
|
||||
var viewControllers: [SearchResultViewController]!
|
||||
|
||||
let searchBar: UISearchBar = {
|
||||
let searchBar = UISearchBar()
|
||||
searchBar.placeholder = L10n.Scene.Search.SearchBar.placeholder
|
||||
searchBar.scopeButtonTitles = SearchDetailViewModel.SearchScope.allCases.map { $0.segmentedControlTitle }
|
||||
return searchBar
|
||||
}()
|
||||
}
|
||||
|
||||
extension SearchDetailViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
navigationItem.setHidesBackButton(true, animated: false)
|
||||
setupSearchBar()
|
||||
|
||||
transition = Transition(style: .fade, duration: 0.1)
|
||||
// transition = nil
|
||||
isScrollEnabled = false
|
||||
|
||||
viewControllers = viewModel.searchScopes.map { scope in
|
||||
let searchResultViewController = SearchResultViewController()
|
||||
searchResultViewController.context = context
|
||||
searchResultViewController.coordinator = coordinator
|
||||
searchResultViewController.viewModel = SearchResultViewModel(context: context, searchScope: scope)
|
||||
// bind searchText
|
||||
viewModel.searchText
|
||||
.assign(to: \.value, on: searchResultViewController.viewModel.searchText)
|
||||
.store(in: &searchResultViewController.disposeBag)
|
||||
return searchResultViewController
|
||||
}
|
||||
|
||||
// set initial items from "all" search scope for non-appeared lists
|
||||
if let allSearchScopeViewController = viewControllers.first(where: { $0.viewModel.searchScope == .all }) {
|
||||
allSearchScopeViewController.viewModel.items
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] items in
|
||||
guard let self = self else { return }
|
||||
guard self.currentViewController === allSearchScopeViewController else { return }
|
||||
for viewController in self.viewControllers where viewController != allSearchScopeViewController {
|
||||
// do not change appeared list
|
||||
guard !viewController.viewModel.viewDidAppear.value else { continue }
|
||||
// set initial items
|
||||
switch viewController.viewModel.searchScope {
|
||||
case .all:
|
||||
assertionFailure()
|
||||
break
|
||||
case .people:
|
||||
viewController.viewModel.items.value = items.filter { item in
|
||||
guard case .account = item else { return false }
|
||||
return true
|
||||
}
|
||||
case .hashtags:
|
||||
viewController.viewModel.items.value = items.filter { item in
|
||||
guard case .hashtag = item else { return false }
|
||||
return true
|
||||
}
|
||||
case .posts:
|
||||
viewController.viewModel.items.value = items.filter { item in
|
||||
guard case .status = item else { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &allSearchScopeViewController.disposeBag)
|
||||
}
|
||||
|
||||
dataSource = self
|
||||
delegate = self
|
||||
|
||||
// bind search bar scope
|
||||
viewModel.selectedSearchScope
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] searchScope in
|
||||
guard let self = self else { return }
|
||||
if let index = self.viewModel.searchScopes.firstIndex(of: searchScope) {
|
||||
self.searchBar.selectedScopeButtonIndex = index
|
||||
self.scrollToPage(.at(index: index), animated: true)
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
// bind search trigger
|
||||
viewModel.searchText
|
||||
.throttle(for: 0.5, scheduler: DispatchQueue.main, latest: true)
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] searchText in
|
||||
guard let self = self else { return }
|
||||
guard let searchResultViewController = self.currentViewController as? SearchResultViewController else {
|
||||
return
|
||||
}
|
||||
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): trigger search \(searchText)")
|
||||
searchResultViewController.viewModel.stateMachine.enter(SearchResultViewModel.State.Loading.self)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
searchBar.setShowsCancelButton(true, animated: animated)
|
||||
searchBar.becomeFirstResponder()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SearchDetailViewController {
|
||||
private func setupSearchBar() {
|
||||
navigationItem.titleView = searchBar
|
||||
searchBar.setShowsScope(true, animated: false)
|
||||
searchBar.sizeToFit()
|
||||
|
||||
searchBar.delegate = self
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UISearchBarDelegate
|
||||
extension SearchDetailViewController: UISearchBarDelegate {
|
||||
|
||||
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
|
||||
viewModel.selectedSearchScope.value = viewModel.searchScopes[selectedScope]
|
||||
}
|
||||
|
||||
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
||||
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): searchTest \(searchText)")
|
||||
viewModel.searchText.value = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
|
||||
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
|
||||
navigationController?.popViewController(animated: true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - PageboyViewControllerDataSource
|
||||
extension SearchDetailViewController: PageboyViewControllerDataSource {
|
||||
|
||||
func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int {
|
||||
return 4
|
||||
}
|
||||
|
||||
func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? {
|
||||
guard index < viewControllers.count else { return nil }
|
||||
return viewControllers[index]
|
||||
}
|
||||
|
||||
func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? {
|
||||
return .first
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - PageboyViewControllerDelegate
|
||||
extension SearchDetailViewController: PageboyViewControllerDelegate {
|
||||
|
||||
func pageboyViewController(
|
||||
_ pageboyViewController: PageboyViewController,
|
||||
willScrollToPageAt index: PageboyViewController.PageIndex,
|
||||
direction: PageboyViewController.NavigationDirection,
|
||||
animated: Bool
|
||||
) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
func pageboyViewController(
|
||||
_ pageboyViewController: PageboyViewController,
|
||||
didScrollTo position: CGPoint,
|
||||
direction: PageboyViewController.NavigationDirection,
|
||||
animated: Bool
|
||||
) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
func pageboyViewController(
|
||||
_ pageboyViewController: PageboyViewController,
|
||||
didCancelScrollToPageAt index: PageboyViewController.PageIndex,
|
||||
returnToPageAt previousIndex: PageboyViewController.PageIndex
|
||||
) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
func pageboyViewController(
|
||||
_ pageboyViewController: PageboyViewController,
|
||||
didScrollToPageAt index: PageboyViewController.PageIndex,
|
||||
direction: PageboyViewController.NavigationDirection,
|
||||
animated: Bool
|
||||
) {
|
||||
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): index \(index)")
|
||||
|
||||
let searchResultViewController = viewControllers[index]
|
||||
viewModel.selectedSearchScope.value = searchResultViewController.viewModel.searchScope
|
||||
|
||||
// trigger fetch
|
||||
searchResultViewController.viewModel.stateMachine.enter(SearchResultViewModel.State.Loading.self)
|
||||
}
|
||||
|
||||
|
||||
func pageboyViewController(
|
||||
_ pageboyViewController: PageboyViewController,
|
||||
didReloadWith currentViewController: UIViewController,
|
||||
currentPageIndex: PageboyViewController.PageIndex
|
||||
) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
//
|
||||
// SearchDetailViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-13.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import Combine
|
||||
import MastodonSDK
|
||||
|
||||
final class SearchDetailViewModel {
|
||||
|
||||
// input
|
||||
var needsBecomeFirstResponder = false
|
||||
let viewDidAppear = PassthroughSubject<Void, Never>()
|
||||
|
||||
// output
|
||||
let searchScopes = SearchScope.allCases
|
||||
let selectedSearchScope = CurrentValueSubject<SearchScope, Never>(.all)
|
||||
let searchText: CurrentValueSubject<String, Never>
|
||||
let searchActionPublisher = PassthroughSubject<Void, Never>()
|
||||
|
||||
init(initialSearchText: String = "") {
|
||||
self.searchText = CurrentValueSubject(initialSearchText)
|
||||
}
|
||||
|
||||
deinit {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SearchDetailViewModel {
|
||||
enum SearchScope: CaseIterable {
|
||||
case all
|
||||
case people
|
||||
case hashtags
|
||||
case posts
|
||||
|
||||
var segmentedControlTitle: String {
|
||||
switch self {
|
||||
case .all: return L10n.Scene.Search.Searching.Segment.all
|
||||
case .people: return L10n.Scene.Search.Searching.Segment.people
|
||||
case .hashtags: return L10n.Scene.Search.Searching.Segment.hashtags
|
||||
case .posts: return L10n.Scene.Search.Searching.Segment.posts
|
||||
}
|
||||
}
|
||||
|
||||
var searchType: Mastodon.API.V2.Search.SearchType {
|
||||
switch self {
|
||||
case .all: return .default
|
||||
case .people: return .accounts
|
||||
case .hashtags: return .hashtags
|
||||
case .posts: return .statuses
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
//
|
||||
// SearchHistoryTableHeaderView.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-14.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// lazy var searchHeader: UIView = {
|
||||
// let view = UIView()
|
||||
// view.frame = CGRect(origin: .zero, size: CGSize(width: searchingTableView.frame.width, height: 56))
|
||||
// return view
|
||||
// }()
|
||||
//
|
||||
// let recentSearchesLabel: UILabel = {
|
||||
// let label = UILabel()
|
||||
// label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
|
||||
// label.textColor = Asset.Colors.Label.primary.color
|
||||
// label.text = L10n.Scene.Search.Searching.recentSearch
|
||||
// return label
|
||||
// }()
|
||||
//
|
||||
// let clearSearchHistoryButton: HighlightDimmableButton = {
|
||||
// let button = HighlightDimmableButton(type: .custom)
|
||||
// button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
|
||||
// button.setTitle(L10n.Scene.Search.Searching.clear, for: .normal)
|
||||
// return button
|
||||
// }()
|
|
@ -0,0 +1,17 @@
|
|||
//
|
||||
// SearchHistoryViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-13.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class SearchHistoryViewController: UIViewController, NeedsDependency {
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
}
|
||||
|
||||
extension SearchHistoryViewController {
|
||||
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
//
|
||||
// SearchResultViewController+StatusProvider.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-14.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
|
||||
// MARK: - StatusProvider
|
||||
extension SearchResultViewController: StatusProvider {
|
||||
|
||||
func status() -> Future<Status?, Never> {
|
||||
return Future { promise in promise(.success(nil)) }
|
||||
}
|
||||
|
||||
func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future<Status?, Never> {
|
||||
return Future { promise in
|
||||
guard let diffableDataSource = self.viewModel.diffableDataSource else {
|
||||
assertionFailure()
|
||||
promise(.success(nil))
|
||||
return
|
||||
}
|
||||
guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
|
||||
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
|
||||
promise(.success(nil))
|
||||
return
|
||||
}
|
||||
|
||||
switch item {
|
||||
case .status(let objectID, _):
|
||||
let managedObjectContext = self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext
|
||||
managedObjectContext.perform {
|
||||
let status = managedObjectContext.object(with: objectID) as? Status
|
||||
promise(.success(status))
|
||||
}
|
||||
default:
|
||||
promise(.success(nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func status(for cell: UICollectionViewCell) -> Future<Status?, Never> {
|
||||
return Future { promise in promise(.success(nil)) }
|
||||
}
|
||||
|
||||
var managedObjectContext: NSManagedObjectContext {
|
||||
return self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext
|
||||
}
|
||||
|
||||
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func items(indexPaths: [IndexPath]) -> [Item] {
|
||||
return []
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SearchResultViewController: UserProvider {}
|
|
@ -0,0 +1,139 @@
|
|||
//
|
||||
// SearchResultViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-14.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import AVKit
|
||||
|
||||
final class SearchResultViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
||||
|
||||
var viewModel: SearchResultViewModel!
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
let tableView: UITableView = {
|
||||
let tableView = UITableView()
|
||||
tableView.register(SearchResultTableViewCell.self, forCellReuseIdentifier: String(describing: SearchResultTableViewCell.self))
|
||||
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
|
||||
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
|
||||
return tableView
|
||||
}()
|
||||
|
||||
}
|
||||
|
||||
extension SearchResultViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
|
||||
ThemeService.shared.currentTheme
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] theme in
|
||||
guard let self = self else { return }
|
||||
self.setupBackgroundColor(theme: theme)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(tableView)
|
||||
NSLayoutConstraint.activate([
|
||||
tableView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
viewModel.setupDiffableDataSource(
|
||||
tableView: tableView,
|
||||
dependency: self,
|
||||
statusTableViewCellDelegate: self
|
||||
)
|
||||
|
||||
// listen keyboard events and set content inset
|
||||
let keyboardEventPublishers = Publishers.CombineLatest3(
|
||||
KeyboardResponderService.shared.isShow,
|
||||
KeyboardResponderService.shared.state,
|
||||
KeyboardResponderService.shared.endFrame
|
||||
)
|
||||
Publishers.CombineLatest(
|
||||
keyboardEventPublishers,
|
||||
viewModel.viewDidAppear
|
||||
)
|
||||
.sink(receiveValue: { [weak self] keyboardEvents, _ in
|
||||
guard let self = self else { return }
|
||||
let (isShow, state, endFrame) = keyboardEvents
|
||||
|
||||
// update keyboard background color
|
||||
guard isShow, state == .dock else {
|
||||
self.tableView.contentInset.bottom = 0
|
||||
self.tableView.verticalScrollIndicatorInsets.bottom = 0
|
||||
return
|
||||
}
|
||||
// isShow AND dock state
|
||||
|
||||
// adjust inset for tableView
|
||||
let contentFrame = self.view.convert(self.tableView.frame, to: nil)
|
||||
let padding = contentFrame.maxY - endFrame.minY
|
||||
guard padding > 0 else {
|
||||
self.tableView.contentInset.bottom = self.view.safeAreaInsets.bottom
|
||||
self.tableView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom
|
||||
return
|
||||
}
|
||||
|
||||
self.tableView.contentInset.bottom = padding - self.view.safeAreaInsets.bottom
|
||||
self.tableView.verticalScrollIndicatorInsets.bottom = padding - self.view.safeAreaInsets.bottom
|
||||
})
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
viewModel.viewDidAppear.value = true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SearchResultViewController {
|
||||
private func setupBackgroundColor(theme: Theme) {
|
||||
view.backgroundColor = theme.systemGroupedBackgroundColor
|
||||
tableView.backgroundColor = theme.systemBackgroundColor
|
||||
// searchHeader.backgroundColor = theme.systemGroupedBackgroundColor
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - AVPlayerViewControllerDelegate
|
||||
extension SearchResultViewController: AVPlayerViewControllerDelegate {
|
||||
|
||||
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||
handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||
}
|
||||
|
||||
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||
handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - StatusTableViewCellDelegate
|
||||
extension SearchResultViewController: StatusTableViewCellDelegate {
|
||||
weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self }
|
||||
func parent() -> UIViewController { return self }
|
||||
}
|
||||
|
||||
//extension SearchResultViewController: LoadMoreConfigurableTableViewContainer {
|
||||
// typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
|
||||
// typealias LoadingState = SearchViewModel.LoadOldestState.Loading
|
||||
// var loadMoreConfigurableTableView: UITableView { searchingTableView }
|
||||
// var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadoldestStateMachine }
|
||||
//}
|
|
@ -0,0 +1,189 @@
|
|||
//
|
||||
// SearchResultViewModel+State.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-14.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import GameplayKit
|
||||
import MastodonSDK
|
||||
|
||||
extension SearchResultViewModel {
|
||||
class State: GKState {
|
||||
weak var viewModel: SearchResultViewModel?
|
||||
|
||||
init(viewModel: SearchResultViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", (#file as NSString).lastPathComponent, #line, #function, debugDescription, previousState.debugDescription)
|
||||
// viewModel?.loadOldestStateMachinePublisher.send(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchResultViewModel.State {
|
||||
class Initial: SearchResultViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
guard let viewModel = viewModel else { return false }
|
||||
return stateClass == Loading.self && !viewModel.searchText.value.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
class Loading: SearchResultViewModel.State {
|
||||
let logger = Logger(subsystem: "SearchResultViewModel.State.Loading", category: "Logic")
|
||||
|
||||
var previousSearchText = ""
|
||||
var offset = 0
|
||||
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
guard let viewModel = self.viewModel else { return false }
|
||||
switch stateClass {
|
||||
case is Fail.Type, is Idle.Type, is NoMore.Type:
|
||||
return true
|
||||
case is Loading.Type:
|
||||
return viewModel.searchText.value != previousSearchText
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
assertionFailure()
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
|
||||
if previousState is Initial {
|
||||
// trigger bottom loader display
|
||||
viewModel.items.value = viewModel.items.value
|
||||
}
|
||||
|
||||
let domain = activeMastodonAuthenticationBox.domain
|
||||
|
||||
let searchText = viewModel.searchText.value
|
||||
let searchType = viewModel.searchScope.searchType
|
||||
|
||||
guard !searchText.isEmpty else {
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
|
||||
if searchText != previousSearchText {
|
||||
previousSearchText = searchText
|
||||
}
|
||||
|
||||
// not set offset for all case
|
||||
// and assert other cases the items are all the same type elements
|
||||
let offset: Int? = {
|
||||
switch searchType {
|
||||
case .default: return nil
|
||||
default:
|
||||
return viewModel.items.value.isEmpty ? nil : viewModel.items.value.count
|
||||
}
|
||||
}()
|
||||
|
||||
let query = Mastodon.API.V2.Search.Query(
|
||||
q: searchText,
|
||||
type: searchType,
|
||||
accountID: nil,
|
||||
maxID: nil,
|
||||
minID: nil,
|
||||
excludeUnreviewed: nil,
|
||||
resolve: nil,
|
||||
limit: nil,
|
||||
offset: offset,
|
||||
following: nil
|
||||
)
|
||||
|
||||
viewModel.context.apiService.search(
|
||||
domain: domain,
|
||||
query: query,
|
||||
mastodonAuthenticationBox: activeMastodonAuthenticationBox
|
||||
)
|
||||
.sink { [weak self] completion in
|
||||
guard let self = self else { return }
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): search \(searchText) fail: \(error.localizedDescription)")
|
||||
stateMachine.enter(Fail.self)
|
||||
case .finished:
|
||||
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): search \(searchText) success")
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self = self else { return }
|
||||
|
||||
// discard result when search text is outdated
|
||||
guard searchText == self.previousSearchText else { return }
|
||||
guard stateMachine.currentState is Loading else { return }
|
||||
|
||||
let oldItems = offset == nil ? [] : viewModel.items.value
|
||||
var newItems: [SearchResultItem] = []
|
||||
|
||||
for account in response.value.accounts {
|
||||
let item = SearchResultItem.account(account: account)
|
||||
guard !oldItems.contains(item) else { continue }
|
||||
newItems.append(item)
|
||||
}
|
||||
for hashtag in response.value.hashtags {
|
||||
let item = SearchResultItem.hashtag(tag: hashtag)
|
||||
guard !oldItems.contains(item) else { continue }
|
||||
newItems.append(item)
|
||||
}
|
||||
if searchType == .default {
|
||||
newItems.sort(by: { ($0.sortKey ?? "") < ($1.sortKey ?? "")})
|
||||
}
|
||||
|
||||
var newStatusIDs = offset == nil ? [] : viewModel.statusFetchedResultsController.statusIDs.value
|
||||
for status in response.value.statuses {
|
||||
guard !newStatusIDs.contains(status.id) else { continue }
|
||||
newStatusIDs.append(status.id)
|
||||
}
|
||||
|
||||
stateMachine.enter(Idle.self)
|
||||
viewModel.items.value = oldItems + newItems
|
||||
viewModel.statusFetchedResultsController.statusIDs.value = newStatusIDs
|
||||
}
|
||||
.store(in: &viewModel.disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
class Fail: SearchResultViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Loading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Idle: SearchResultViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Loading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NoMore: SearchResultViewModel.State {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
switch stateClass {
|
||||
case is Loading.Type:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
//
|
||||
// SearchResultViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-14.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import GameplayKit
|
||||
|
||||
final class SearchResultViewModel {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let searchScope: SearchDetailViewModel.SearchScope
|
||||
let searchText = CurrentValueSubject<String, Never>("")
|
||||
let statusFetchedResultsController: StatusFetchedResultsController
|
||||
let viewDidAppear = CurrentValueSubject<Bool, Never>(false)
|
||||
|
||||
// output
|
||||
private(set) lazy var stateMachine: GKStateMachine = {
|
||||
let stateMachine = GKStateMachine(states: [
|
||||
State.Initial(viewModel: self),
|
||||
State.Loading(viewModel: self),
|
||||
State.Fail(viewModel: self),
|
||||
State.Idle(viewModel: self),
|
||||
State.NoMore(viewModel: self),
|
||||
])
|
||||
stateMachine.enter(State.Initial.self)
|
||||
return stateMachine
|
||||
}()
|
||||
let items = CurrentValueSubject<[SearchResultItem], Never>([])
|
||||
var diffableDataSource: UITableViewDiffableDataSource<SearchResultSection, SearchResultItem>!
|
||||
|
||||
init(context: AppContext, searchScope: SearchDetailViewModel.SearchScope) {
|
||||
self.context = context
|
||||
self.searchScope = searchScope
|
||||
self.statusFetchedResultsController = StatusFetchedResultsController(
|
||||
managedObjectContext: context.managedObjectContext,
|
||||
domain: nil,
|
||||
additionalTweetPredicate: nil
|
||||
)
|
||||
|
||||
context.authenticationService.activeMastodonAuthenticationBox
|
||||
.map { $0?.domain }
|
||||
.assign(to: \.value, on: statusFetchedResultsController.domain)
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest(
|
||||
items,
|
||||
statusFetchedResultsController.objectIDs.removeDuplicates()
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] items, statusObjectIDs in
|
||||
guard let self = self else { return }
|
||||
guard let diffableDataSource = self.diffableDataSource else { return }
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<SearchResultSection, SearchResultItem>()
|
||||
snapshot.appendSections([.main])
|
||||
|
||||
// append account & hashtag items
|
||||
snapshot.appendItems(items, toSection: .main)
|
||||
|
||||
var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
|
||||
let oldSnapshot = diffableDataSource.snapshot()
|
||||
for item in oldSnapshot.itemIdentifiers {
|
||||
guard case let .status(objectID, attribute) = item else { continue }
|
||||
oldSnapshotAttributeDict[objectID] = attribute
|
||||
}
|
||||
|
||||
// append statuses
|
||||
var statusItems: [SearchResultItem] = []
|
||||
for objectID in statusObjectIDs {
|
||||
let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute()
|
||||
statusItems.append(.status(statusObjectID: objectID, attribute: attribute))
|
||||
}
|
||||
snapshot.appendItems(statusItems, toSection: .main)
|
||||
|
||||
if let currentState = self.stateMachine.currentState {
|
||||
switch currentState {
|
||||
case is State.Loading, is State.Fail, is State.Fail:
|
||||
snapshot.appendItems([.bottomLoader], toSection: .main)
|
||||
case is State.NoMore:
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
diffableDataSource.defaultRowAnimation = .fade
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: true)
|
||||
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SearchResultViewModel {
|
||||
func setupDiffableDataSource(
|
||||
tableView: UITableView,
|
||||
dependency: NeedsDependency,
|
||||
statusTableViewCellDelegate: StatusTableViewCellDelegate
|
||||
) {
|
||||
diffableDataSource = SearchResultSection.tableViewDiffableDataSource(
|
||||
for: tableView,
|
||||
dependency: dependency,
|
||||
statusTableViewCellDelegate: statusTableViewCellDelegate
|
||||
)
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<SearchResultSection, SearchResultItem>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(self.items.value, toSection: .main) // with initial items
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
}
|
|
@ -13,7 +13,7 @@ import UIKit
|
|||
import FLAnimatedImage
|
||||
import Nuke
|
||||
|
||||
final class SearchingTableViewCell: UITableViewCell {
|
||||
final class SearchResultTableViewCell: UITableViewCell {
|
||||
|
||||
let _imageView: UIImageView = {
|
||||
let imageView = FLAnimatedImageView()
|
||||
|
@ -54,7 +54,7 @@ final class SearchingTableViewCell: UITableViewCell {
|
|||
}
|
||||
}
|
||||
|
||||
extension SearchingTableViewCell {
|
||||
extension SearchResultTableViewCell {
|
||||
private func configure() {
|
||||
let containerStackView = UIStackView()
|
||||
containerStackView.axis = .horizontal
|
||||
|
@ -74,8 +74,8 @@ extension SearchingTableViewCell {
|
|||
_imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerStackView.addArrangedSubview(_imageView)
|
||||
NSLayoutConstraint.activate([
|
||||
_imageView.widthAnchor.constraint(equalToConstant: 42),
|
||||
_imageView.heightAnchor.constraint(equalToConstant: 42),
|
||||
_imageView.widthAnchor.constraint(equalToConstant: 42).priority(.required - 1),
|
||||
_imageView.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1),
|
||||
])
|
||||
|
||||
let textStackView = UIStackView()
|
||||
|
@ -120,7 +120,7 @@ extension SearchingTableViewCell {
|
|||
func config(with tag: Mastodon.Entity.Tag) {
|
||||
let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate)
|
||||
_imageView.image = image
|
||||
_titleLabel.text = "# " + tag.name
|
||||
_titleLabel.text = "#" + tag.name
|
||||
guard let histories = tag.history else {
|
||||
_subTitleLabel.text = ""
|
||||
return
|
||||
|
@ -151,11 +151,11 @@ extension SearchingTableViewCell {
|
|||
#if canImport(SwiftUI) && DEBUG
|
||||
import SwiftUI
|
||||
|
||||
struct SearchingTableViewCell_Previews: PreviewProvider {
|
||||
struct SearchResultTableViewCell_Previews: PreviewProvider {
|
||||
static var controls: some View {
|
||||
Group {
|
||||
UIViewPreview {
|
||||
let cell = SearchingTableViewCell()
|
||||
let cell = SearchResultTableViewCell()
|
||||
cell.backgroundColor = .white
|
||||
cell._imageView.image = UIImage(systemName: "number.circle.fill")
|
||||
cell._titleLabel.text = "Electronic Frontier Foundation"
|
|
@ -1,93 +0,0 @@
|
|||
//
|
||||
// SearchViewController+Searching.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
import OSLog
|
||||
import UIKit
|
||||
|
||||
extension SearchViewController {
|
||||
func setupSearchingTableView() {
|
||||
searchingTableView.register(SearchingTableViewCell.self, forCellReuseIdentifier: String(describing: SearchingTableViewCell.self))
|
||||
searchingTableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
|
||||
searchingTableView.estimatedRowHeight = 66
|
||||
searchingTableView.rowHeight = 66
|
||||
view.addSubview(searchingTableView)
|
||||
searchingTableView.delegate = self
|
||||
searchingTableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
searchingTableView.topAnchor.constraint(equalTo: searchBar.bottomAnchor),
|
||||
searchingTableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||
searchingTableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||
searchingTableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
||||
])
|
||||
searchingTableView.tableFooterView = UIView()
|
||||
searchingTableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||
viewModel.isSearching
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isSearching in
|
||||
self?.searchingTableView.isHidden = !isSearching
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest(
|
||||
viewModel.isSearching,
|
||||
viewModel.searchText
|
||||
)
|
||||
.sink { [weak self] isSearching, text in
|
||||
guard let self = self else { return }
|
||||
if isSearching, text.isEmpty {
|
||||
self.searchingTableView.tableHeaderView = self.searchHeader
|
||||
} else {
|
||||
self.searchingTableView.tableHeaderView = nil
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
func setupSearchHeader() {
|
||||
let containerStackView = UIStackView()
|
||||
containerStackView.axis = .horizontal
|
||||
containerStackView.distribution = .fill
|
||||
containerStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerStackView.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
|
||||
containerStackView.isLayoutMarginsRelativeArrangement = true
|
||||
searchHeader.addSubview(containerStackView)
|
||||
NSLayoutConstraint.activate([
|
||||
containerStackView.topAnchor.constraint(equalTo: searchHeader.topAnchor),
|
||||
containerStackView.leadingAnchor.constraint(equalTo: searchHeader.leadingAnchor),
|
||||
containerStackView.trailingAnchor.constraint(equalTo: searchHeader.trailingAnchor),
|
||||
containerStackView.bottomAnchor.constraint(equalTo: searchHeader.bottomAnchor)
|
||||
])
|
||||
recentSearchesLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerStackView.addArrangedSubview(recentSearchesLabel)
|
||||
clearSearchHistoryButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerStackView.addArrangedSubview(clearSearchHistoryButton)
|
||||
clearSearchHistoryButton.addTarget(self, action: #selector(SearchViewController.clearAction(_:)), for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchViewController {
|
||||
@objc func clearAction(_ sender: UIButton) {
|
||||
viewModel.deleteSearchHistory()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
|
||||
extension SearchViewController: UITableViewDelegate {
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard let diffableDataSource = viewModel.searchResultDiffableDataSource else { return }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
viewModel.searchResultItemDidSelected(item: item, from: self)
|
||||
}
|
||||
}
|
|
@ -1,295 +0,0 @@
|
|||
//
|
||||
// SearchViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/31.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import GameplayKit
|
||||
import MastodonSDK
|
||||
import UIKit
|
||||
|
||||
final class SearchViewController: UIViewController, NeedsDependency {
|
||||
|
||||
public static var hashtagCardHeight: CGFloat {
|
||||
get {
|
||||
if UIScreen.main.bounds.size.height > 736 {
|
||||
return 186
|
||||
}
|
||||
return 130
|
||||
}
|
||||
}
|
||||
|
||||
public static var hashtagPeopleTalkingLabelTop: CGFloat {
|
||||
get {
|
||||
if UIScreen.main.bounds.size.height > 736 {
|
||||
return 18
|
||||
}
|
||||
return 6
|
||||
}
|
||||
}
|
||||
public static let accountCardHeight = 202
|
||||
|
||||
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
private(set) lazy var viewModel = SearchViewModel(context: context, coordinator: coordinator)
|
||||
|
||||
let statusBar: UIView = {
|
||||
let view = UIView()
|
||||
return view
|
||||
}()
|
||||
|
||||
let searchBar: UISearchBar = {
|
||||
let searchBar = UISearchBar()
|
||||
searchBar.placeholder = L10n.Scene.Search.Searchbar.placeholder
|
||||
searchBar.translatesAutoresizingMaskIntoConstraints = false
|
||||
// let micImage = UIImage(systemName: "mic.fill")
|
||||
// searchBar.setImage(micImage, for: .bookmark, state: .normal)
|
||||
// searchBar.showsBookmarkButton = true
|
||||
searchBar.scopeButtonTitles = [L10n.Scene.Search.Searching.Segment.all, L10n.Scene.Search.Searching.Segment.people, L10n.Scene.Search.Searching.Segment.hashtags]
|
||||
return searchBar
|
||||
}()
|
||||
|
||||
// recommend
|
||||
let scrollView: UIScrollView = {
|
||||
let scrollView = UIScrollView()
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.alwaysBounceVertical = true
|
||||
scrollView.clipsToBounds = false
|
||||
return scrollView
|
||||
}()
|
||||
|
||||
let stackView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .vertical
|
||||
stackView.distribution = .fill
|
||||
return stackView
|
||||
}()
|
||||
|
||||
let hashtagCollectionView: UICollectionView = {
|
||||
let flowLayout = UICollectionViewFlowLayout()
|
||||
flowLayout.scrollDirection = .horizontal
|
||||
let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
|
||||
view.backgroundColor = .clear
|
||||
view.showsHorizontalScrollIndicator = false
|
||||
view.showsVerticalScrollIndicator = false
|
||||
view.layer.masksToBounds = false
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
return view
|
||||
}()
|
||||
|
||||
let accountsCollectionView: UICollectionView = {
|
||||
let flowLayout = UICollectionViewFlowLayout()
|
||||
flowLayout.scrollDirection = .horizontal
|
||||
let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
|
||||
view.backgroundColor = .clear
|
||||
view.showsHorizontalScrollIndicator = false
|
||||
view.showsVerticalScrollIndicator = false
|
||||
view.layer.masksToBounds = false
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
return view
|
||||
}()
|
||||
|
||||
// searching
|
||||
let searchingTableView: UITableView = {
|
||||
let tableView = UITableView()
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.separatorStyle = .singleLine
|
||||
tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
|
||||
tableView.separatorColor = UIView.separatorColor
|
||||
return tableView
|
||||
}()
|
||||
|
||||
lazy var searchHeader: UIView = {
|
||||
let view = UIView()
|
||||
view.frame = CGRect(origin: .zero, size: CGSize(width: searchingTableView.frame.width, height: 56))
|
||||
return view
|
||||
}()
|
||||
|
||||
let recentSearchesLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
|
||||
label.textColor = Asset.Colors.Label.primary.color
|
||||
label.text = L10n.Scene.Search.Searching.recentSearch
|
||||
return label
|
||||
}()
|
||||
|
||||
let clearSearchHistoryButton: HighlightDimmableButton = {
|
||||
let button = HighlightDimmableButton(type: .custom)
|
||||
button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
|
||||
button.setTitle(L10n.Scene.Search.Searching.clear, for: .normal)
|
||||
return button
|
||||
}()
|
||||
}
|
||||
|
||||
extension SearchViewController {
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
let barAppearance = UINavigationBarAppearance()
|
||||
barAppearance.configureWithTransparentBackground()
|
||||
navigationItem.standardAppearance = barAppearance
|
||||
navigationItem.compactAppearance = barAppearance
|
||||
navigationItem.scrollEdgeAppearance = barAppearance
|
||||
|
||||
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
|
||||
ThemeService.shared.currentTheme
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] theme in
|
||||
guard let self = self else { return }
|
||||
self.setupBackgroundColor(theme: theme)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
navigationItem.hidesBackButton = true
|
||||
|
||||
setupSearchBar()
|
||||
setupScrollView()
|
||||
setupHashTagCollectionView()
|
||||
setupAccountsCollectionView()
|
||||
setupSearchingTableView()
|
||||
setupDataSource()
|
||||
setupSearchHeader()
|
||||
view.bringSubviewToFront(searchBar)
|
||||
view.bringSubviewToFront(statusBar)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
viewModel.viewDidAppeared.send()
|
||||
}
|
||||
|
||||
private func setupBackgroundColor(theme: Theme) {
|
||||
view.backgroundColor = theme.systemGroupedBackgroundColor
|
||||
searchHeader.backgroundColor = theme.systemGroupedBackgroundColor
|
||||
searchingTableView.backgroundColor = theme.systemBackgroundColor
|
||||
statusBar.backgroundColor = theme.navigationBarBackgroundColor
|
||||
}
|
||||
|
||||
func setupSearchBar() {
|
||||
searchBar.delegate = self
|
||||
view.addSubview(searchBar)
|
||||
searchBar.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
searchBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||
searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
searchBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
])
|
||||
|
||||
statusBar.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(statusBar)
|
||||
NSLayoutConstraint.activate([
|
||||
statusBar.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
statusBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
statusBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
statusBar.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 3),
|
||||
])
|
||||
}
|
||||
|
||||
func setupScrollView() {
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// scrollView
|
||||
view.addSubview(scrollView)
|
||||
NSLayoutConstraint.activate([
|
||||
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: searchBar.frame.height),
|
||||
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
view.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor),
|
||||
scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
scrollView.frameLayoutGuide.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor),
|
||||
])
|
||||
|
||||
// stackview
|
||||
scrollView.addSubview(stackView)
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
|
||||
stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
|
||||
stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
|
||||
stackView.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor),
|
||||
scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: stackView.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
func setupDataSource() {
|
||||
viewModel.hashtagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashtagCollectionView)
|
||||
viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView, delegate: self, managedObjectContext: context.managedObjectContext)
|
||||
viewModel.searchResultDiffableDataSource = SearchResultSection.tableViewDiffableDataSource(for: searchingTableView, dependency: self)
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchViewController: UIScrollViewDelegate {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
if scrollView == searchingTableView {
|
||||
handleScrollViewDidScroll(scrollView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchViewController: UISearchBarDelegate {
|
||||
func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
|
||||
searchBar.setShowsCancelButton(true, animated: true)
|
||||
searchBar.showsScopeBar = true
|
||||
viewModel.isSearching.value = true
|
||||
}
|
||||
|
||||
func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
|
||||
searchBar.setShowsCancelButton(false, animated: true)
|
||||
searchBar.showsScopeBar = false
|
||||
viewModel.isSearching.value = true
|
||||
}
|
||||
|
||||
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
|
||||
searchBar.setShowsCancelButton(false, animated: true)
|
||||
searchBar.showsScopeBar = false
|
||||
searchBar.text = ""
|
||||
searchBar.resignFirstResponder()
|
||||
viewModel.isSearching.value = false
|
||||
}
|
||||
|
||||
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
||||
viewModel.searchText.send(searchText)
|
||||
}
|
||||
|
||||
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
|
||||
switch selectedScope {
|
||||
case 0:
|
||||
viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.default
|
||||
case 1:
|
||||
viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.accounts
|
||||
case 2:
|
||||
viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.hashtags
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func searchBarBookmarkButtonClicked(_ searchBar: UISearchBar) {}
|
||||
}
|
||||
|
||||
extension SearchViewController: LoadMoreConfigurableTableViewContainer {
|
||||
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
|
||||
typealias LoadingState = SearchViewModel.LoadOldestState.Loading
|
||||
var loadMoreConfigurableTableView: UITableView { searchingTableView }
|
||||
var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadoldestStateMachine }
|
||||
}
|
||||
|
||||
#if canImport(SwiftUI) && DEBUG
|
||||
import SwiftUI
|
||||
|
||||
struct SearchViewController_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
UIViewControllerPreview {
|
||||
let viewController = SearchViewController()
|
||||
return viewController
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 800))
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -1,146 +0,0 @@
|
|||
//
|
||||
// SearchViewModel+LoadOldestState.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/6.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import GameplayKit
|
||||
import MastodonSDK
|
||||
import os.log
|
||||
|
||||
extension SearchViewModel {
|
||||
class LoadOldestState: GKState {
|
||||
weak var viewModel: SearchViewModel?
|
||||
|
||||
init(viewModel: SearchViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", (#file as NSString).lastPathComponent, #line, #function, debugDescription, previousState.debugDescription)
|
||||
viewModel?.loadOldestStateMachinePublisher.send(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchViewModel.LoadOldestState {
|
||||
class Initial: SearchViewModel.LoadOldestState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
guard let viewModel = viewModel else { return false }
|
||||
guard viewModel.searchResult.value != nil else { return false }
|
||||
return stateClass == Loading.self
|
||||
}
|
||||
}
|
||||
|
||||
class Loading: SearchViewModel.LoadOldestState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
super.didEnter(from: previousState)
|
||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
assertionFailure()
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
guard let oldSearchResult = viewModel.searchResult.value else {
|
||||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
var offset = 0
|
||||
switch viewModel.searchScope.value {
|
||||
case Mastodon.API.V2.Search.SearchType.accounts:
|
||||
offset = oldSearchResult.accounts.count
|
||||
case Mastodon.API.V2.Search.SearchType.hashtags:
|
||||
offset = oldSearchResult.hashtags.count
|
||||
default:
|
||||
return
|
||||
}
|
||||
let query = Mastodon.API.V2.Search.Query(q: viewModel.searchText.value,
|
||||
type: viewModel.searchScope.value,
|
||||
accountID: nil,
|
||||
maxID: nil,
|
||||
minID: nil,
|
||||
excludeUnreviewed: nil,
|
||||
resolve: nil,
|
||||
limit: nil,
|
||||
offset: offset,
|
||||
following: nil)
|
||||
|
||||
viewModel.context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
||||
.sink { completion in
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
os_log("%{public}s[%{public}ld], %{public}s: load oldest search failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
|
||||
case .finished:
|
||||
// handle isFetchingLatestTimeline in fetch controller delegate
|
||||
break
|
||||
}
|
||||
} receiveValue: { result in
|
||||
switch viewModel.searchScope.value {
|
||||
case Mastodon.API.V2.Search.SearchType.accounts:
|
||||
if result.value.accounts.isEmpty {
|
||||
stateMachine.enter(NoMore.self)
|
||||
} else {
|
||||
var newAccounts = [Mastodon.Entity.Account]()
|
||||
newAccounts.append(contentsOf: oldSearchResult.accounts)
|
||||
newAccounts.append(contentsOf: result.value.accounts)
|
||||
newAccounts.removeDuplicates()
|
||||
viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: newAccounts, statuses: oldSearchResult.statuses, hashtags: oldSearchResult.hashtags)
|
||||
stateMachine.enter(Idle.self)
|
||||
}
|
||||
case Mastodon.API.V2.Search.SearchType.hashtags:
|
||||
if result.value.hashtags.isEmpty {
|
||||
stateMachine.enter(NoMore.self)
|
||||
} else {
|
||||
var newTags = [Mastodon.Entity.Tag]()
|
||||
newTags.append(contentsOf: oldSearchResult.hashtags)
|
||||
newTags.append(contentsOf: result.value.hashtags)
|
||||
newTags.removeDuplicates()
|
||||
viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: oldSearchResult.accounts, statuses: oldSearchResult.statuses, hashtags: newTags)
|
||||
stateMachine.enter(Idle.self)
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
.store(in: &viewModel.disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
class Fail: SearchViewModel.LoadOldestState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
stateClass == Loading.self || stateClass == Idle.self
|
||||
}
|
||||
}
|
||||
|
||||
class Idle: SearchViewModel.LoadOldestState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
stateClass == Loading.self
|
||||
}
|
||||
}
|
||||
|
||||
class NoMore: SearchViewModel.LoadOldestState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
// reset state if needs
|
||||
stateClass == Idle.self
|
||||
}
|
||||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
guard let viewModel = viewModel else { return }
|
||||
guard let diffableDataSource = viewModel.searchResultDiffableDataSource else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
var snapshot = diffableDataSource.snapshot()
|
||||
snapshot.deleteItems([.bottomLoader])
|
||||
diffableDataSource.apply(snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import UIKit
|
||||
|
||||
// Make status bar style adptive for child view controller
|
||||
// Make status bar style adaptive for child view controller
|
||||
// SeeAlso: `modalPresentationCapturesStatusBarAppearance`
|
||||
final class AdaptiveStatusBarStyleNavigationController: UINavigationController {
|
||||
var viewControllersHiddenNavigationBar: [UIViewController.Type]
|
||||
|
@ -19,7 +19,7 @@ final class AdaptiveStatusBarStyleNavigationController: UINavigationController {
|
|||
override init(rootViewController: UIViewController) {
|
||||
self.viewControllersHiddenNavigationBar = [SearchViewController.self]
|
||||
super.init(rootViewController: rootViewController)
|
||||
self.delegate = self
|
||||
// self.delegate = self
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
|
@ -28,13 +28,13 @@ final class AdaptiveStatusBarStyleNavigationController: UINavigationController {
|
|||
}
|
||||
}
|
||||
|
||||
extension AdaptiveStatusBarStyleNavigationController: UINavigationControllerDelegate {
|
||||
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
|
||||
let isContain = self.viewControllersHiddenNavigationBar.contains { type(of: viewController) == $0 }
|
||||
if isContain {
|
||||
self.setNavigationBarHidden(true, animated: animated)
|
||||
} else {
|
||||
self.setNavigationBarHidden(false, animated: animated)
|
||||
}
|
||||
}
|
||||
}
|
||||
//extension AdaptiveStatusBarStyleNavigationController: UINavigationControllerDelegate {
|
||||
// func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
|
||||
// let isContain = self.viewControllersHiddenNavigationBar.contains { type(of: viewController) == $0 }
|
||||
// if isContain {
|
||||
// self.setNavigationBarHidden(true, animated: animated)
|
||||
// } else {
|
||||
// self.setNavigationBarHidden(false, animated: animated)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
//
|
||||
// SearchDetailTransitionController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-13.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class SearchDetailTransitionController: NSObject {
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UINavigationControllerDelegate
|
||||
extension SearchDetailTransitionController: UINavigationControllerDelegate {
|
||||
func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
switch operation {
|
||||
case .push where fromVC is SearchViewController && toVC is SearchDetailViewController:
|
||||
return SearchToSearchDetailViewControllerAnimatedTransitioning(operation: operation)
|
||||
case .pop where fromVC is SearchDetailViewController && toVC is SearchViewController:
|
||||
return SearchToSearchDetailViewControllerAnimatedTransitioning(operation: operation)
|
||||
default:
|
||||
// fix edge dismiss gesture
|
||||
toVC.navigationController?.interactivePopGestureRecognizer?.delegate = nil
|
||||
// assertionFailure("Wrong setup. Edge-drag gesture will be invalid. Set delegate to nil when using system push configuration")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
//
|
||||
// SearchToSearchDetailViewControllerAnimatedTransitioning.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-7-13.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
|
||||
final class SearchToSearchDetailViewControllerAnimatedTransitioning: ViewControllerAnimatedTransitioning {
|
||||
|
||||
private var animator: UIViewPropertyAnimator?
|
||||
|
||||
override init(operation: UINavigationController.Operation) {
|
||||
super.init(operation: operation)
|
||||
|
||||
self.transitionDuration = 0.01
|
||||
}
|
||||
|
||||
deinit {
|
||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UIViewControllerAnimatedTransitioning
|
||||
extension SearchToSearchDetailViewControllerAnimatedTransitioning {
|
||||
|
||||
override func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
super.animateTransition(using: transitionContext)
|
||||
|
||||
switch operation {
|
||||
case .push: pushTransition(using: transitionContext).startAnimation()
|
||||
case .pop: popTransition(using: transitionContext).startAnimation()
|
||||
default: return
|
||||
}
|
||||
}
|
||||
|
||||
private func pushTransition(using transitionContext: UIViewControllerContextTransitioning, curve: UIView.AnimationCurve = .easeInOut) -> UIViewPropertyAnimator {
|
||||
guard let toVC = transitionContext.viewController(forKey: .to) as? SearchDetailViewController,
|
||||
let toView = transitionContext.view(forKey: .to) else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
let toViewEndFrame = transitionContext.finalFrame(for: toVC)
|
||||
transitionContext.containerView.addSubview(toView)
|
||||
toView.frame = toViewEndFrame
|
||||
|
||||
let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), curve: curve)
|
||||
animator.addAnimations {
|
||||
|
||||
}
|
||||
animator.addCompletion { position in
|
||||
transitionContext.completeTransition(true)
|
||||
}
|
||||
return animator
|
||||
}
|
||||
|
||||
private func popTransition(using transitionContext: UIViewControllerContextTransitioning, curve: UIView.AnimationCurve = .easeInOut) -> UIViewPropertyAnimator {
|
||||
guard let toVC = transitionContext.viewController(forKey: .to) as? SearchViewController,
|
||||
let toView = transitionContext.view(forKey: .to) else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
let toViewEndFrame = transitionContext.finalFrame(for: toVC)
|
||||
transitionContext.containerView.addSubview(toView)
|
||||
toView.frame = toViewEndFrame
|
||||
|
||||
let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), curve: curve)
|
||||
animator.addAnimations {
|
||||
|
||||
}
|
||||
animator.addCompletion { position in
|
||||
transitionContext.completeTransition(true)
|
||||
}
|
||||
return animator
|
||||
}
|
||||
}
|
|
@ -17,6 +17,7 @@ class ViewControllerAnimatedTransitioning: NSObject {
|
|||
|
||||
let operation: UINavigationController.Operation
|
||||
|
||||
var transitionDuration: TimeInterval
|
||||
var transitionContext: UIViewControllerContextTransitioning!
|
||||
var isInteractive: Bool { return transitionContext.isInteractive }
|
||||
|
||||
|
@ -25,6 +26,7 @@ class ViewControllerAnimatedTransitioning: NSObject {
|
|||
init(operation: UINavigationController.Operation) {
|
||||
assert(operation != .none)
|
||||
self.operation = operation
|
||||
self.transitionDuration = 0.3
|
||||
super.init()
|
||||
}
|
||||
|
||||
|
@ -38,7 +40,7 @@ class ViewControllerAnimatedTransitioning: NSObject {
|
|||
extension ViewControllerAnimatedTransitioning: UIViewControllerAnimatedTransitioning {
|
||||
|
||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||
return 0.3
|
||||
return transitionDuration
|
||||
}
|
||||
|
||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
|
|
|
@ -6,8 +6,9 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
import Combine
|
||||
import MastodonSDK
|
||||
import CommonOSLog
|
||||
|
||||
extension APIService {
|
||||
|
||||
|
@ -17,7 +18,32 @@ extension APIService {
|
|||
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> {
|
||||
let authorization = mastodonAuthenticationBox.userAuthorization
|
||||
let requestMastodonUserID = mastodonAuthenticationBox.userID
|
||||
|
||||
return Mastodon.API.V2.Search.search(session: session, domain: domain, query: query, authorization: authorization)
|
||||
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> in
|
||||
// persist status
|
||||
let statusResponse = response.map { $0.statuses }
|
||||
return APIService.Persist.persistStatus(
|
||||
managedObjectContext: self.backgroundManagedObjectContext,
|
||||
domain: domain,
|
||||
query: nil,
|
||||
response: statusResponse,
|
||||
persistType: .lookUp,
|
||||
requestMastodonUserID: requestMastodonUserID,
|
||||
log: OSLog.api
|
||||
)
|
||||
.setFailureType(to: Error.self)
|
||||
.tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.SearchResult> in
|
||||
switch result {
|
||||
case .success:
|
||||
return response
|
||||
case .failure(let error):
|
||||
throw error
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -105,8 +105,8 @@ extension Mastodon.API.V2.Search {
|
|||
}
|
||||
}
|
||||
|
||||
public extension Mastodon.API.V2.Search {
|
||||
enum SearchType: String, Codable {
|
||||
extension Mastodon.API.V2.Search {
|
||||
public enum SearchType: String, Codable {
|
||||
case accounts
|
||||
case hashtags
|
||||
case statuses
|
||||
|
|
Loading…
Reference in New Issue