forked from zelo72/mastodon-ios
Merge pull request #110 from tootsuite/feature/suggestion
Feature/suggestion
This commit is contained in:
commit
bcdb55f9f0
|
@ -51,7 +51,9 @@
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
"share": "Share",
|
"share": "Share",
|
||||||
"share_user": "Share %s",
|
"share_user": "Share %s",
|
||||||
"open_in_safari": "Open in Safari"
|
"open_in_safari": "Open in Safari",
|
||||||
|
"find_people": "Find people to follow",
|
||||||
|
"manually_search": "Manually search instead"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"user_reblogged": "%s reblogged",
|
"user_reblogged": "%s reblogged",
|
||||||
|
@ -231,6 +233,10 @@
|
||||||
"Publishing": "Publishing post..."
|
"Publishing": "Publishing post..."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"suggestion_account": {
|
||||||
|
"title": "Find People to Follow",
|
||||||
|
"follow_explain": "When you follow someone, you’ll see their posts in your home feed."
|
||||||
|
},
|
||||||
"public_timeline": {
|
"public_timeline": {
|
||||||
"title": "Public"
|
"title": "Public"
|
||||||
},
|
},
|
||||||
|
@ -330,7 +336,7 @@
|
||||||
},
|
},
|
||||||
"favorite": {
|
"favorite": {
|
||||||
"title": "Your Favorites"
|
"title": "Your Favorites"
|
||||||
},
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"title": {
|
"title": {
|
||||||
"Everything": "Everything",
|
"Everything": "Everything",
|
||||||
|
@ -342,6 +348,7 @@
|
||||||
"reblog": "rebloged your post",
|
"reblog": "rebloged your post",
|
||||||
"poll": "Your poll has ended",
|
"poll": "Your poll has ended",
|
||||||
"mention": "mentioned you"
|
"mention": "mentioned you"
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"thread": {
|
"thread": {
|
||||||
"back_title": "Post",
|
"back_title": "Post",
|
||||||
|
|
|
@ -74,6 +74,9 @@
|
||||||
2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; };
|
2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; };
|
||||||
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */; };
|
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */; };
|
||||||
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */; };
|
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */; };
|
||||||
|
2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */; };
|
||||||
|
2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */; };
|
||||||
|
2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */; };
|
||||||
2D571B2F26004EC000540450 /* NavigationBarProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */; };
|
2D571B2F26004EC000540450 /* NavigationBarProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */; };
|
||||||
2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */; };
|
2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */; };
|
||||||
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */; };
|
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */; };
|
||||||
|
@ -120,6 +123,9 @@
|
||||||
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; };
|
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; };
|
||||||
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; };
|
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; };
|
||||||
2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; };
|
2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; };
|
||||||
|
2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */; };
|
||||||
|
2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */; };
|
||||||
|
2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */; };
|
||||||
2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */; };
|
2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */; };
|
||||||
2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */; };
|
2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */; };
|
||||||
2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */; };
|
2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */; };
|
||||||
|
@ -488,6 +494,9 @@
|
||||||
2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = "<group>"; };
|
2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = "<group>"; };
|
||||||
2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = "<group>"; };
|
2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = "<group>"; };
|
||||||
2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = "<group>"; };
|
2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = "<group>"; };
|
||||||
|
2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
|
2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedAccountSection.swift; sourceTree = "<group>"; };
|
||||||
|
2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedAccountItem.swift; sourceTree = "<group>"; };
|
||||||
2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarProgressView.swift; sourceTree = "<group>"; };
|
2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarProgressView.swift; sourceTree = "<group>"; };
|
||||||
2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewController.swift; sourceTree = "<group>"; };
|
2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewController.swift; sourceTree = "<group>"; };
|
||||||
2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewModel.swift; sourceTree = "<group>"; };
|
2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
@ -532,6 +541,9 @@
|
||||||
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = "<group>"; };
|
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = "<group>"; };
|
||||||
2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
|
2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
|
||||||
2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
|
2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
|
||||||
|
2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountViewController.swift; sourceTree = "<group>"; };
|
||||||
|
2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountTableViewCell.swift; sourceTree = "<group>"; };
|
||||||
2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Notification+Type.swift"; sourceTree = "<group>"; };
|
2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Notification+Type.swift"; sourceTree = "<group>"; };
|
||||||
2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendCollectionHeader.swift; sourceTree = "<group>"; };
|
2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendCollectionHeader.swift; sourceTree = "<group>"; };
|
||||||
2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendHashTagSection.swift; sourceTree = "<group>"; };
|
2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendHashTagSection.swift; sourceTree = "<group>"; };
|
||||||
|
@ -1018,6 +1030,14 @@
|
||||||
path = Button;
|
path = Button;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
2D4AD89A2631659400613EFC /* CollectionViewCell */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */,
|
||||||
|
);
|
||||||
|
path = CollectionViewCell;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
2D59819925E4A55C000FB903 /* ConfirmEmail */ = {
|
2D59819925E4A55C000FB903 /* ConfirmEmail */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -1116,6 +1136,7 @@
|
||||||
DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */,
|
DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */,
|
||||||
2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */,
|
2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */,
|
||||||
2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */,
|
2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */,
|
||||||
|
2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */,
|
||||||
2D35237926256D920031AF25 /* NotificationSection.swift */,
|
2D35237926256D920031AF25 /* NotificationSection.swift */,
|
||||||
2D198648261C0B8500F0B013 /* SearchResultSection.swift */,
|
2D198648261C0B8500F0B013 /* SearchResultSection.swift */,
|
||||||
DB66729525F9F91600D60309 /* ComposeStatusSection.swift */,
|
DB66729525F9F91600D60309 /* ComposeStatusSection.swift */,
|
||||||
|
@ -1170,6 +1191,7 @@
|
||||||
children = (
|
children = (
|
||||||
2D7631B225C159F700929FB9 /* Item.swift */,
|
2D7631B225C159F700929FB9 /* Item.swift */,
|
||||||
2D198642261BF09500F0B013 /* SearchResultItem.swift */,
|
2D198642261BF09500F0B013 /* SearchResultItem.swift */,
|
||||||
|
2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */,
|
||||||
2D7867182625B77500211898 /* NotificationItem.swift */,
|
2D7867182625B77500211898 /* NotificationItem.swift */,
|
||||||
DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */,
|
DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */,
|
||||||
DB1E347725F519300079D7DF /* PickServerItem.swift */,
|
DB1E347725F519300079D7DF /* PickServerItem.swift */,
|
||||||
|
@ -1188,6 +1210,25 @@
|
||||||
path = Decoration;
|
path = Decoration;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */,
|
||||||
|
2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */,
|
||||||
|
2D4AD89A2631659400613EFC /* CollectionViewCell */,
|
||||||
|
2DAC9E43262FC9DE0062E1A6 /* TableViewCell */,
|
||||||
|
);
|
||||||
|
path = SuggestionAccount;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
2DAC9E43262FC9DE0062E1A6 /* TableViewCell */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */,
|
||||||
|
);
|
||||||
|
path = TableViewCell;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
2DE0FAC62615F5D200CDF649 /* View */ = {
|
2DE0FAC62615F5D200CDF649 /* View */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -1675,6 +1716,7 @@
|
||||||
2D38F1D325CD463600561493 /* HomeTimeline */,
|
2D38F1D325CD463600561493 /* HomeTimeline */,
|
||||||
2D76316325C14BAC00929FB9 /* PublicTimeline */,
|
2D76316325C14BAC00929FB9 /* PublicTimeline */,
|
||||||
0F2021F5261325ED000C64BF /* HashtagTimeline */,
|
0F2021F5261325ED000C64BF /* HashtagTimeline */,
|
||||||
|
2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */,
|
||||||
DB9D6BEE25E4F5370051B173 /* Search */,
|
DB9D6BEE25E4F5370051B173 /* Search */,
|
||||||
5B90C455262599800002E742 /* Settings */,
|
5B90C455262599800002E742 /* Settings */,
|
||||||
DB9D6BFD25E4F57B0051B173 /* Notification */,
|
DB9D6BFD25E4F57B0051B173 /* Notification */,
|
||||||
|
@ -2391,6 +2433,7 @@
|
||||||
DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */,
|
DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */,
|
||||||
DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */,
|
DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */,
|
||||||
2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */,
|
2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */,
|
||||||
|
2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */,
|
||||||
DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */,
|
DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */,
|
||||||
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */,
|
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */,
|
||||||
2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */,
|
2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */,
|
||||||
|
@ -2407,6 +2450,7 @@
|
||||||
DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */,
|
DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */,
|
||||||
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */,
|
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */,
|
||||||
DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */,
|
DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */,
|
||||||
|
2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */,
|
||||||
DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */,
|
DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */,
|
||||||
DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */,
|
DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */,
|
||||||
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */,
|
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */,
|
||||||
|
@ -2431,6 +2475,7 @@
|
||||||
DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */,
|
DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */,
|
||||||
DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */,
|
DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */,
|
||||||
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */,
|
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */,
|
||||||
|
2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */,
|
||||||
DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */,
|
DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */,
|
||||||
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */,
|
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */,
|
||||||
2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */,
|
2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */,
|
||||||
|
@ -2509,6 +2554,7 @@
|
||||||
2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */,
|
2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */,
|
||||||
DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */,
|
DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */,
|
||||||
DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */,
|
DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */,
|
||||||
|
2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */,
|
||||||
DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */,
|
DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */,
|
||||||
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
|
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
|
||||||
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
|
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
|
||||||
|
@ -2532,6 +2578,7 @@
|
||||||
DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */,
|
DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */,
|
||||||
2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */,
|
2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */,
|
||||||
DB084B5725CBC56C00F898ED /* Status.swift in Sources */,
|
DB084B5725CBC56C00F898ED /* Status.swift in Sources */,
|
||||||
|
2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */,
|
||||||
DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */,
|
DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */,
|
||||||
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
|
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
|
||||||
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */,
|
DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */,
|
||||||
|
@ -2597,6 +2644,7 @@
|
||||||
DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */,
|
DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */,
|
||||||
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */,
|
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */,
|
||||||
0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */,
|
0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */,
|
||||||
|
2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */,
|
||||||
DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */,
|
DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */,
|
||||||
DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */,
|
DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */,
|
||||||
DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */,
|
DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */,
|
||||||
|
|
|
@ -13,6 +13,7 @@ final public class SceneCoordinator {
|
||||||
private weak var scene: UIScene!
|
private weak var scene: UIScene!
|
||||||
private weak var sceneDelegate: SceneDelegate!
|
private weak var sceneDelegate: SceneDelegate!
|
||||||
private weak var appContext: AppContext!
|
private weak var appContext: AppContext!
|
||||||
|
private weak var tabBarController: MainTabBarController!
|
||||||
|
|
||||||
let id = UUID().uuidString
|
let id = UUID().uuidString
|
||||||
|
|
||||||
|
@ -61,6 +62,8 @@ extension SceneCoordinator {
|
||||||
case profile(viewModel: ProfileViewModel)
|
case profile(viewModel: ProfileViewModel)
|
||||||
case favorite(viewModel: FavoriteViewModel)
|
case favorite(viewModel: FavoriteViewModel)
|
||||||
|
|
||||||
|
// suggestion account
|
||||||
|
case suggestionAccount(viewModel: SuggestionAccountViewModel)
|
||||||
// misc
|
// misc
|
||||||
case safari(url: URL)
|
case safari(url: URL)
|
||||||
case alertController(alertController: UIAlertController)
|
case alertController(alertController: UIAlertController)
|
||||||
|
@ -93,6 +96,7 @@ extension SceneCoordinator {
|
||||||
func setup() {
|
func setup() {
|
||||||
let viewController = MainTabBarController(context: appContext, coordinator: self)
|
let viewController = MainTabBarController(context: appContext, coordinator: self)
|
||||||
sceneDelegate.window?.rootViewController = viewController
|
sceneDelegate.window?.rootViewController = viewController
|
||||||
|
tabBarController = viewController
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupOnboardingIfNeeds(animated: Bool) {
|
func setupOnboardingIfNeeds(animated: Bool) {
|
||||||
|
@ -187,6 +191,9 @@ extension SceneCoordinator {
|
||||||
return viewController
|
return viewController
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func switchToTabBar(tab: MainTabBarController.Tab) {
|
||||||
|
tabBarController.selectedIndex = tab.rawValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension SceneCoordinator {
|
private extension SceneCoordinator {
|
||||||
|
@ -246,6 +253,10 @@ private extension SceneCoordinator {
|
||||||
let _viewController = FavoriteViewController()
|
let _viewController = FavoriteViewController()
|
||||||
_viewController.viewModel = viewModel
|
_viewController.viewModel = viewModel
|
||||||
viewController = _viewController
|
viewController = _viewController
|
||||||
|
case .suggestionAccount(let viewModel):
|
||||||
|
let _viewController = SuggestionAccountViewController()
|
||||||
|
_viewController.viewModel = viewModel
|
||||||
|
viewController = _viewController
|
||||||
case .safari(let url):
|
case .safari(let url):
|
||||||
guard let scheme = url.scheme?.lowercased(),
|
guard let scheme = url.scheme?.lowercased(),
|
||||||
scheme == "http" || scheme == "https" else {
|
scheme == "http" || scheme == "https" else {
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
//
|
||||||
|
// SelectedAccountItem.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by sxiaojian on 2021/4/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum SelectedAccountItem {
|
||||||
|
case accountObjectID(accountObjectID: NSManagedObjectID)
|
||||||
|
case placeHolder(uuid: UUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SelectedAccountItem: Equatable {
|
||||||
|
static func == (lhs: SelectedAccountItem, rhs: SelectedAccountItem) -> Bool {
|
||||||
|
switch (lhs, rhs) {
|
||||||
|
case (.accountObjectID(let idLeft), .accountObjectID(let idRight)):
|
||||||
|
return idLeft == idRight
|
||||||
|
case (.placeHolder(let uuidLeft), .placeHolder(let uuidRight)):
|
||||||
|
return uuidLeft == uuidRight
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SelectedAccountItem: Hashable {
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
switch self {
|
||||||
|
case .accountObjectID(let id):
|
||||||
|
hasher.combine(id)
|
||||||
|
case .placeHolder(let id):
|
||||||
|
hasher.combine(id.uuidString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -29,4 +29,21 @@ extension RecommendAccountSection {
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func tableViewDiffableDataSource(
|
||||||
|
for tableView: UITableView,
|
||||||
|
managedObjectContext: NSManagedObjectContext,
|
||||||
|
viewModel: SuggestionAccountViewModel,
|
||||||
|
delegate: SuggestionAccountTableViewCellDelegate
|
||||||
|
) -> UITableViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID> {
|
||||||
|
UITableViewDiffableDataSource(tableView: tableView) { [weak viewModel, weak delegate] (tableView, indexPath, objectID) -> UITableViewCell? in
|
||||||
|
guard let viewModel = viewModel else { return nil }
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SuggestionAccountTableViewCell.self)) as! SuggestionAccountTableViewCell
|
||||||
|
let user = managedObjectContext.object(with: objectID) as! MastodonUser
|
||||||
|
let isSelected = viewModel.selectedAccounts.value.contains(objectID)
|
||||||
|
cell.delegate = delegate
|
||||||
|
cell.config(with: user, isSelected: isSelected)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
//
|
||||||
|
// SelectedAccountSection.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by sxiaojian on 2021/4/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
import Foundation
|
||||||
|
import MastodonSDK
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum SelectedAccountSection: Equatable, Hashable {
|
||||||
|
case main
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SelectedAccountSection {
|
||||||
|
static func collectionViewDiffableDataSource(
|
||||||
|
for collectionView: UICollectionView,
|
||||||
|
managedObjectContext: NSManagedObjectContext
|
||||||
|
) -> UICollectionViewDiffableDataSource<SelectedAccountSection, SelectedAccountItem> {
|
||||||
|
UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in
|
||||||
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SuggestionAccountCollectionViewCell.self), for: indexPath) as! SuggestionAccountCollectionViewCell
|
||||||
|
switch item {
|
||||||
|
case .accountObjectID(let objectID):
|
||||||
|
let user = managedObjectContext.object(with: objectID) as! MastodonUser
|
||||||
|
cell.config(with: user)
|
||||||
|
case .placeHolder:
|
||||||
|
cell.configAsPlaceHolder()
|
||||||
|
}
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -69,6 +69,7 @@ internal enum Asset {
|
||||||
internal static let highlight = ColorAsset(name: "Colors/Label/highlight")
|
internal static let highlight = ColorAsset(name: "Colors/Label/highlight")
|
||||||
internal static let primary = ColorAsset(name: "Colors/Label/primary")
|
internal static let primary = ColorAsset(name: "Colors/Label/primary")
|
||||||
internal static let secondary = ColorAsset(name: "Colors/Label/secondary")
|
internal static let secondary = ColorAsset(name: "Colors/Label/secondary")
|
||||||
|
internal static let tertiary = ColorAsset(name: "Colors/Label/tertiary")
|
||||||
}
|
}
|
||||||
internal enum Notification {
|
internal enum Notification {
|
||||||
internal static let favourite = ColorAsset(name: "Colors/Notification/favourite")
|
internal static let favourite = ColorAsset(name: "Colors/Notification/favourite")
|
||||||
|
|
|
@ -72,6 +72,10 @@ internal enum L10n {
|
||||||
internal static let done = L10n.tr("Localizable", "Common.Controls.Actions.Done")
|
internal static let done = L10n.tr("Localizable", "Common.Controls.Actions.Done")
|
||||||
/// Edit
|
/// Edit
|
||||||
internal static let edit = L10n.tr("Localizable", "Common.Controls.Actions.Edit")
|
internal static let edit = L10n.tr("Localizable", "Common.Controls.Actions.Edit")
|
||||||
|
/// Find people to follow
|
||||||
|
internal static let findPeople = L10n.tr("Localizable", "Common.Controls.Actions.FindPeople")
|
||||||
|
/// Manually search instead
|
||||||
|
internal static let manuallySearch = L10n.tr("Localizable", "Common.Controls.Actions.ManuallySearch")
|
||||||
/// OK
|
/// OK
|
||||||
internal static let ok = L10n.tr("Localizable", "Common.Controls.Actions.Ok")
|
internal static let ok = L10n.tr("Localizable", "Common.Controls.Actions.Ok")
|
||||||
/// Open in Safari
|
/// Open in Safari
|
||||||
|
@ -679,6 +683,12 @@ internal enum L10n {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
internal enum SuggestionAccount {
|
||||||
|
/// When you follow someone, you’ll see their posts in your home feed.
|
||||||
|
internal static let followExplain = L10n.tr("Localizable", "Scene.SuggestionAccount.FollowExplain")
|
||||||
|
/// Find People to Follow
|
||||||
|
internal static let title = L10n.tr("Localizable", "Scene.SuggestionAccount.Title")
|
||||||
|
}
|
||||||
internal enum Thread {
|
internal enum Thread {
|
||||||
/// Post
|
/// Post
|
||||||
internal static let backTitle = L10n.tr("Localizable", "Scene.Thread.BackTitle")
|
internal static let backTitle = L10n.tr("Localizable", "Scene.Thread.BackTitle")
|
||||||
|
|
|
@ -44,7 +44,8 @@ extension UserProviderFacade {
|
||||||
|
|
||||||
return context.apiService.toggleFollow(
|
return context.apiService.toggleFollow(
|
||||||
for: mastodonUser,
|
for: mastodonUser,
|
||||||
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox
|
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox,
|
||||||
|
needFeedback: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.switchToLatest()
|
.switchToLatest()
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "0.300",
|
||||||
|
"blue" : "67",
|
||||||
|
"green" : "60",
|
||||||
|
"red" : "60"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
|
@ -20,6 +20,8 @@ Please check your internet connection.";
|
||||||
"Common.Controls.Actions.Discard" = "Discard";
|
"Common.Controls.Actions.Discard" = "Discard";
|
||||||
"Common.Controls.Actions.Done" = "Done";
|
"Common.Controls.Actions.Done" = "Done";
|
||||||
"Common.Controls.Actions.Edit" = "Edit";
|
"Common.Controls.Actions.Edit" = "Edit";
|
||||||
|
"Common.Controls.Actions.FindPeople" = "Find people to follow";
|
||||||
|
"Common.Controls.Actions.ManuallySearch" = "Manually search instead";
|
||||||
"Common.Controls.Actions.Ok" = "OK";
|
"Common.Controls.Actions.Ok" = "OK";
|
||||||
"Common.Controls.Actions.OpenInSafari" = "Open in Safari";
|
"Common.Controls.Actions.OpenInSafari" = "Open in Safari";
|
||||||
"Common.Controls.Actions.Preview" = "Preview";
|
"Common.Controls.Actions.Preview" = "Preview";
|
||||||
|
@ -221,6 +223,8 @@ any server.";
|
||||||
"Scene.Settings.Section.Spicyzone.Signout" = "Sign Out";
|
"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.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";
|
||||||
"Scene.Thread.BackTitle" = "Post";
|
"Scene.Thread.BackTitle" = "Post";
|
||||||
"Scene.Thread.Favorite.Multiple" = "%@ favorites";
|
"Scene.Thread.Favorite.Multiple" = "%@ favorites";
|
||||||
"Scene.Thread.Favorite.Single" = "%@ favorite";
|
"Scene.Thread.Favorite.Single" = "%@ favorite";
|
||||||
|
@ -228,4 +232,4 @@ any server.";
|
||||||
"Scene.Thread.Reblog.Single" = "%@ reblog";
|
"Scene.Thread.Reblog.Single" = "%@ reblog";
|
||||||
"Scene.Thread.Title" = "Post from %@";
|
"Scene.Thread.Title" = "Post from %@";
|
||||||
"Scene.Welcome.Slogan" = "Social networking
|
"Scene.Welcome.Slogan" = "Social networking
|
||||||
back in your hands.";
|
back in your hands.";
|
|
@ -84,7 +84,7 @@ final class HashtagTimelineViewModel: NSObject {
|
||||||
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
|
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let query = Mastodon.API.Search.Query(q: hashtag, type: .hashtags)
|
let query = Mastodon.API.V2.Search.Query(q: hashtag, type: .hashtags)
|
||||||
context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
||||||
.sink { _ in
|
.sink { _ in
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,14 @@ extension HomeTimelineViewController {
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.showWelcomeAction(action)
|
self.showWelcomeAction(action)
|
||||||
},
|
},
|
||||||
|
UIAction(title: "Show Or Remove EmptyView", image: UIImage(systemName: "clear"), attributes: []) { [weak self] action in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if self.emptyView.superview != nil {
|
||||||
|
self.emptyView.removeFromSuperview()
|
||||||
|
} else {
|
||||||
|
self.showEmptyView()
|
||||||
|
}
|
||||||
|
},
|
||||||
UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in
|
UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.showPublicTimelineAction(action)
|
self.showPublicTimelineAction(action)
|
||||||
|
|
|
@ -23,6 +23,15 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency {
|
||||||
var disposeBag = Set<AnyCancellable>()
|
var disposeBag = Set<AnyCancellable>()
|
||||||
private(set) lazy var viewModel = HomeTimelineViewModel(context: context)
|
private(set) lazy var viewModel = HomeTimelineViewModel(context: context)
|
||||||
|
|
||||||
|
lazy var emptyView: UIStackView = {
|
||||||
|
let emptyView = UIStackView()
|
||||||
|
emptyView.axis = .vertical
|
||||||
|
emptyView.distribution = .fill
|
||||||
|
emptyView.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 54, right: 20)
|
||||||
|
emptyView.isLayoutMarginsRelativeArrangement = true
|
||||||
|
return emptyView
|
||||||
|
}()
|
||||||
|
|
||||||
let titleView = HomeTimelineNavigationBarTitleView()
|
let titleView = HomeTimelineNavigationBarTitleView()
|
||||||
|
|
||||||
let settingBarButtonItem: UIBarButtonItem = {
|
let settingBarButtonItem: UIBarButtonItem = {
|
||||||
|
@ -142,7 +151,7 @@ extension HomeTimelineViewController {
|
||||||
UIView.animate(withDuration: 0.5) { [weak self] in
|
UIView.animate(withDuration: 0.5) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.refreshControl.endRefreshing()
|
self.refreshControl.endRefreshing()
|
||||||
}
|
} completion: { _ in }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
@ -173,6 +182,17 @@ extension HomeTimelineViewController {
|
||||||
self.publishProgressView.setProgress(progress, animated: true)
|
self.publishProgressView.setProgress(progress, animated: true)
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
viewModel.timelineIsEmpty
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] isEmpty in
|
||||||
|
if isEmpty {
|
||||||
|
self?.showEmptyView()
|
||||||
|
} else {
|
||||||
|
self?.emptyView.removeFromSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
@ -182,6 +202,10 @@ extension HomeTimelineViewController {
|
||||||
|
|
||||||
// needs trigger manually after onboarding dismiss
|
// needs trigger manually after onboarding dismiss
|
||||||
setNeedsStatusBarAppearanceUpdate()
|
setNeedsStatusBarAppearanceUpdate()
|
||||||
|
|
||||||
|
if (viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty {
|
||||||
|
viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
@ -217,6 +241,58 @@ extension HomeTimelineViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension HomeTimelineViewController {
|
extension HomeTimelineViewController {
|
||||||
|
func showEmptyView() {
|
||||||
|
if emptyView.superview != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view.addSubview(emptyView)
|
||||||
|
emptyView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
emptyView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
|
||||||
|
emptyView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor),
|
||||||
|
emptyView.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
if emptyView.arrangedSubviews.count > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let findPeopleButton: PrimaryActionButton = {
|
||||||
|
let button = PrimaryActionButton()
|
||||||
|
button.setTitle(L10n.Common.Controls.Actions.findPeople, for: .normal)
|
||||||
|
button.addTarget(self, action: #selector(HomeTimelineViewController.findPeopleButtonPressed(_:)), for: .touchUpInside)
|
||||||
|
return button
|
||||||
|
}()
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
findPeopleButton.heightAnchor.constraint(equalToConstant: 46)
|
||||||
|
])
|
||||||
|
|
||||||
|
let manuallySearchButton: HighlightDimmableButton = {
|
||||||
|
let button = HighlightDimmableButton()
|
||||||
|
button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold))
|
||||||
|
button.setTitle(L10n.Common.Controls.Actions.manuallySearch, for: .normal)
|
||||||
|
button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
|
||||||
|
button.addTarget(self, action: #selector(HomeTimelineViewController.manuallySearchButtonPressed(_:)), for: .touchUpInside)
|
||||||
|
return button
|
||||||
|
}()
|
||||||
|
|
||||||
|
emptyView.addArrangedSubview(findPeopleButton)
|
||||||
|
emptyView.setCustomSpacing(17, after: findPeopleButton)
|
||||||
|
emptyView.addArrangedSubview(manuallySearchButton)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HomeTimelineViewController {
|
||||||
|
|
||||||
|
@objc private func findPeopleButtonPressed(_ sender: PrimaryActionButton) {
|
||||||
|
let viewModel = SuggestionAccountViewModel(context: context)
|
||||||
|
viewModel.delegate = self.viewModel
|
||||||
|
coordinator.present(scene: .suggestionAccount(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func manuallySearchButtonPressed(_ sender: UIButton) {
|
||||||
|
coordinator.switchToTabBar(tab: .search)
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
@objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
|
|
@ -107,6 +107,7 @@ extension HomeTimelineViewModel.LoadLatestState {
|
||||||
viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming()
|
viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModel.timelineIsEmpty.value = latestStatusIDs.isEmpty && statuses.isEmpty
|
||||||
}
|
}
|
||||||
.store(in: &viewModel.disposeBag)
|
.store(in: &viewModel.disposeBag)
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,6 +34,8 @@ final class HomeTimelineViewModel: NSObject {
|
||||||
weak var tableView: UITableView?
|
weak var tableView: UITableView?
|
||||||
weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
|
weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
|
||||||
|
|
||||||
|
let timelineIsEmpty = CurrentValueSubject<Bool, Never>(false)
|
||||||
|
let homeTimelineNeedRefresh = PassthroughSubject<Void, Never>()
|
||||||
// output
|
// output
|
||||||
// top loader
|
// top loader
|
||||||
private(set) lazy var loadLatestStateMachine: GKStateMachine = {
|
private(set) lazy var loadLatestStateMachine: GKStateMachine = {
|
||||||
|
@ -122,6 +124,12 @@ final class HomeTimelineViewModel: NSObject {
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
homeTimelineNeedRefresh
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.loadLatestStateMachine.enter(LoadLatestState.Loading.self)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
@ -129,3 +137,5 @@ final class HomeTimelineViewModel: NSObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension HomeTimelineViewModel: SuggestionAccountViewModelDelegate { }
|
||||||
|
|
|
@ -9,7 +9,7 @@ import UIKit
|
||||||
|
|
||||||
final class ProfileRelationshipActionButton: RoundedEdgesButton {
|
final class ProfileRelationshipActionButton: RoundedEdgesButton {
|
||||||
|
|
||||||
let actvityIndicatorView: UIActivityIndicatorView = {
|
let activityIndicatorView: UIActivityIndicatorView = {
|
||||||
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
|
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
|
||||||
activityIndicatorView.color = .white
|
activityIndicatorView.color = .white
|
||||||
return activityIndicatorView
|
return activityIndicatorView
|
||||||
|
@ -31,15 +31,15 @@ extension ProfileRelationshipActionButton {
|
||||||
private func _init() {
|
private func _init() {
|
||||||
titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold)
|
titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
|
||||||
actvityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
|
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
addSubview(actvityIndicatorView)
|
addSubview(activityIndicatorView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
actvityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||||
actvityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||||
])
|
])
|
||||||
|
|
||||||
actvityIndicatorView.hidesWhenStopped = true
|
activityIndicatorView.hidesWhenStopped = true
|
||||||
actvityIndicatorView.stopAnimating()
|
activityIndicatorView.stopAnimating()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,13 +52,13 @@ extension ProfileRelationshipActionButton {
|
||||||
setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted)
|
setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted)
|
||||||
setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled)
|
setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled)
|
||||||
|
|
||||||
actvityIndicatorView.stopAnimating()
|
activityIndicatorView.stopAnimating()
|
||||||
|
|
||||||
if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked || option == .suspended {
|
if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked || option == .suspended {
|
||||||
isEnabled = false
|
isEnabled = false
|
||||||
} else if actionOptionSet.contains(.updating) {
|
} else if actionOptionSet.contains(.updating) {
|
||||||
isEnabled = false
|
isEnabled = false
|
||||||
actvityIndicatorView.startAnimating()
|
activityIndicatorView.startAnimating()
|
||||||
} else {
|
} else {
|
||||||
isEnabled = true
|
isEnabled = true
|
||||||
}
|
}
|
||||||
|
|
|
@ -98,10 +98,7 @@ extension SearchRecommendAccountsCollectionViewCell {
|
||||||
headerImageView.layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor
|
headerImageView.layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor
|
||||||
applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0)
|
applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0)
|
||||||
}
|
}
|
||||||
override open func layoutSubviews() {
|
|
||||||
super.layoutSubviews()
|
|
||||||
followButton.layer.cornerRadius = followButton.frame.height/2
|
|
||||||
}
|
|
||||||
private func configure() {
|
private func configure() {
|
||||||
headerImageView.backgroundColor = Asset.Colors.brandBlue.color
|
headerImageView.backgroundColor = Asset.Colors.brandBlue.color
|
||||||
layer.cornerRadius = 10
|
layer.cornerRadius = 10
|
||||||
|
|
|
@ -83,7 +83,6 @@ extension SearchRecommendTagsCollectionViewCell {
|
||||||
let containerStackView = UIStackView()
|
let containerStackView = UIStackView()
|
||||||
containerStackView.axis = .vertical
|
containerStackView.axis = .vertical
|
||||||
containerStackView.distribution = .fill
|
containerStackView.distribution = .fill
|
||||||
containerStackView.spacing = 6
|
|
||||||
containerStackView.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 0, right: 16)
|
containerStackView.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 0, right: 16)
|
||||||
containerStackView.isLayoutMarginsRelativeArrangement = true
|
containerStackView.isLayoutMarginsRelativeArrangement = true
|
||||||
containerStackView.translatesAutoresizingMaskIntoConstraints = false
|
containerStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
@ -113,6 +112,7 @@ extension SearchRecommendTagsCollectionViewCell {
|
||||||
peopleLabel.translatesAutoresizingMaskIntoConstraints = false
|
peopleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
peopleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical)
|
peopleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical)
|
||||||
containerStackView.addArrangedSubview(peopleLabel)
|
containerStackView.addArrangedSubview(peopleLabel)
|
||||||
|
containerStackView.setCustomSpacing(SearchViewController.hashtagPeopleTalkingLabelTop, after: horizontalStackView)
|
||||||
}
|
}
|
||||||
|
|
||||||
func config(with tag: Mastodon.Entity.Tag) {
|
func config(with tag: Mastodon.Entity.Tag) {
|
||||||
|
|
|
@ -26,7 +26,7 @@ extension SearchViewController {
|
||||||
hashtagCollectionView.translatesAutoresizingMaskIntoConstraints = false
|
hashtagCollectionView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
stackView.addArrangedSubview(hashtagCollectionView)
|
stackView.addArrangedSubview(hashtagCollectionView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
hashtagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 130)
|
hashtagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: CGFloat(SearchViewController.hashtagCardHeight))
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,7 +43,7 @@ extension SearchViewController {
|
||||||
accountsCollectionView.translatesAutoresizingMaskIntoConstraints = false
|
accountsCollectionView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
stackView.addArrangedSubview(accountsCollectionView)
|
stackView.addArrangedSubview(accountsCollectionView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
accountsCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 202)
|
accountsCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: CGFloat(SearchViewController.accountCardHeight))
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,9 +91,9 @@ extension SearchViewController: UICollectionViewDelegateFlowLayout {
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||||||
if collectionView == hashtagCollectionView {
|
if collectionView == hashtagCollectionView {
|
||||||
return CGSize(width: 228, height: 130)
|
return CGSize(width: 228, height: SearchViewController.hashtagCardHeight)
|
||||||
} else {
|
} else {
|
||||||
return CGSize(width: 257, height: 202)
|
return CGSize(width: 257, height: SearchViewController.accountCardHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -101,5 +101,11 @@ extension SearchViewController: UICollectionViewDelegateFlowLayout {
|
||||||
extension SearchViewController {
|
extension SearchViewController {
|
||||||
@objc func hashtagSeeAllButtonPressed(_ sender: UIButton) {}
|
@objc func hashtagSeeAllButtonPressed(_ sender: UIButton) {}
|
||||||
|
|
||||||
@objc func accountSeeAllButtonPressed(_ sender: UIButton) {}
|
@objc func accountSeeAllButtonPressed(_ sender: UIButton) {
|
||||||
|
if self.viewModel.recommendAccounts.isEmpty {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let viewModel = SuggestionAccountViewModel(context: context, accounts: self.viewModel.recommendAccounts)
|
||||||
|
coordinator.present(scene: .suggestionAccount(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,26 @@ import MastodonSDK
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
final class SearchViewController: UIViewController, NeedsDependency {
|
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 context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||||
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||||
|
|
||||||
|
@ -49,9 +69,6 @@ final class SearchViewController: UIViewController, NeedsDependency {
|
||||||
let stackView = UIStackView()
|
let stackView = UIStackView()
|
||||||
stackView.axis = .vertical
|
stackView.axis = .vertical
|
||||||
stackView.distribution = .fill
|
stackView.distribution = .fill
|
||||||
stackView.spacing = 0
|
|
||||||
stackView.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 68, right: 0)
|
|
||||||
stackView.isLayoutMarginsRelativeArrangement = true
|
|
||||||
return stackView
|
return stackView
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
@ -130,6 +147,8 @@ extension SearchViewController {
|
||||||
setupSearchingTableView()
|
setupSearchingTableView()
|
||||||
setupDataSource()
|
setupDataSource()
|
||||||
setupSearchHeader()
|
setupSearchHeader()
|
||||||
|
view.bringSubviewToFront(searchBar)
|
||||||
|
view.bringSubviewToFront(statusBar)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupSearchBar() {
|
func setupSearchBar() {
|
||||||
|
@ -148,23 +167,27 @@ extension SearchViewController {
|
||||||
statusBar.topAnchor.constraint(equalTo: view.topAnchor),
|
statusBar.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
statusBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
statusBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
statusBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
statusBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
statusBar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 3),
|
statusBar.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 3),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupScrollView() {
|
func setupScrollView() {
|
||||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
// scrollView
|
||||||
view.addSubview(scrollView)
|
view.addSubview(scrollView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: searchBar.bottomAnchor),
|
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: searchBar.frame.height),
|
||||||
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
view.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor),
|
||||||
scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
scrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor),
|
scrollView.frameLayoutGuide.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor),
|
||||||
])
|
])
|
||||||
|
|
||||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
// stackview
|
||||||
scrollView.addSubview(stackView)
|
scrollView.addSubview(stackView)
|
||||||
|
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
|
stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
|
||||||
stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
|
stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
|
||||||
|
@ -217,11 +240,11 @@ extension SearchViewController: UISearchBarDelegate {
|
||||||
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
|
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
|
||||||
switch selectedScope {
|
switch selectedScope {
|
||||||
case 0:
|
case 0:
|
||||||
viewModel.searchScope.value = Mastodon.API.Search.SearchType.default
|
viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.default
|
||||||
case 1:
|
case 1:
|
||||||
viewModel.searchScope.value = Mastodon.API.Search.SearchType.accounts
|
viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.accounts
|
||||||
case 2:
|
case 2:
|
||||||
viewModel.searchScope.value = Mastodon.API.Search.SearchType.hashtags
|
viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.hashtags
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
@ -53,14 +53,14 @@ extension SearchViewModel.LoadOldestState {
|
||||||
}
|
}
|
||||||
var offset = 0
|
var offset = 0
|
||||||
switch viewModel.searchScope.value {
|
switch viewModel.searchScope.value {
|
||||||
case Mastodon.API.Search.SearchType.accounts:
|
case Mastodon.API.V2.Search.SearchType.accounts:
|
||||||
offset = oldSearchResult.accounts.count
|
offset = oldSearchResult.accounts.count
|
||||||
case Mastodon.API.Search.SearchType.hashtags:
|
case Mastodon.API.V2.Search.SearchType.hashtags:
|
||||||
offset = oldSearchResult.hashtags.count
|
offset = oldSearchResult.hashtags.count
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let query = Mastodon.API.Search.Query(q: viewModel.searchText.value,
|
let query = Mastodon.API.V2.Search.Query(q: viewModel.searchText.value,
|
||||||
type: viewModel.searchScope.value,
|
type: viewModel.searchScope.value,
|
||||||
accountID: nil,
|
accountID: nil,
|
||||||
maxID: nil,
|
maxID: nil,
|
||||||
|
@ -82,7 +82,7 @@ extension SearchViewModel.LoadOldestState {
|
||||||
}
|
}
|
||||||
} receiveValue: { result in
|
} receiveValue: { result in
|
||||||
switch viewModel.searchScope.value {
|
switch viewModel.searchScope.value {
|
||||||
case Mastodon.API.Search.SearchType.accounts:
|
case Mastodon.API.V2.Search.SearchType.accounts:
|
||||||
if result.value.accounts.isEmpty {
|
if result.value.accounts.isEmpty {
|
||||||
stateMachine.enter(NoMore.self)
|
stateMachine.enter(NoMore.self)
|
||||||
} else {
|
} else {
|
||||||
|
@ -93,7 +93,7 @@ extension SearchViewModel.LoadOldestState {
|
||||||
viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: newAccounts, statuses: oldSearchResult.statuses, hashtags: oldSearchResult.hashtags)
|
viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: newAccounts, statuses: oldSearchResult.statuses, hashtags: oldSearchResult.hashtags)
|
||||||
stateMachine.enter(Idle.self)
|
stateMachine.enter(Idle.self)
|
||||||
}
|
}
|
||||||
case Mastodon.API.Search.SearchType.hashtags:
|
case Mastodon.API.V2.Search.SearchType.hashtags:
|
||||||
if result.value.hashtags.isEmpty {
|
if result.value.hashtags.isEmpty {
|
||||||
stateMachine.enter(NoMore.self)
|
stateMachine.enter(NoMore.self)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -26,7 +26,7 @@ final class SearchViewModel: NSObject {
|
||||||
|
|
||||||
// output
|
// output
|
||||||
let searchText = CurrentValueSubject<String, Never>("")
|
let searchText = CurrentValueSubject<String, Never>("")
|
||||||
let searchScope = CurrentValueSubject<Mastodon.API.Search.SearchType, Never>(Mastodon.API.Search.SearchType.default)
|
let searchScope = CurrentValueSubject<Mastodon.API.V2.Search.SearchType, Never>(Mastodon.API.V2.Search.SearchType.default)
|
||||||
|
|
||||||
let isSearching = CurrentValueSubject<Bool, Never>(false)
|
let isSearching = CurrentValueSubject<Bool, Never>(false)
|
||||||
|
|
||||||
|
@ -34,6 +34,7 @@ final class SearchViewModel: NSObject {
|
||||||
|
|
||||||
var recommendHashTags = [Mastodon.Entity.Tag]()
|
var recommendHashTags = [Mastodon.Entity.Tag]()
|
||||||
var recommendAccounts = [NSManagedObjectID]()
|
var recommendAccounts = [NSManagedObjectID]()
|
||||||
|
var recommendAccountsFallback = PassthroughSubject<Void, Never>()
|
||||||
|
|
||||||
var hashtagDiffableDataSource: UICollectionViewDiffableDataSource<RecommendHashTagSection, Mastodon.Entity.Tag>?
|
var hashtagDiffableDataSource: UICollectionViewDiffableDataSource<RecommendHashTagSection, Mastodon.Entity.Tag>?
|
||||||
var accountDiffableDataSource: UICollectionViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID>?
|
var accountDiffableDataSource: UICollectionViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID>?
|
||||||
|
@ -86,16 +87,16 @@ final class SearchViewModel: NSObject {
|
||||||
}
|
}
|
||||||
.flatMap { (text, scope) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> in
|
.flatMap { (text, scope) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> in
|
||||||
|
|
||||||
let query = Mastodon.API.Search.Query(q: text,
|
let query = Mastodon.API.V2.Search.Query(q: text,
|
||||||
type: scope,
|
type: scope,
|
||||||
accountID: nil,
|
accountID: nil,
|
||||||
maxID: nil,
|
maxID: nil,
|
||||||
minID: nil,
|
minID: nil,
|
||||||
excludeUnreviewed: nil,
|
excludeUnreviewed: nil,
|
||||||
resolve: nil,
|
resolve: nil,
|
||||||
limit: nil,
|
limit: nil,
|
||||||
offset: nil,
|
offset: nil,
|
||||||
following: nil)
|
following: nil)
|
||||||
return context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
return context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
||||||
}
|
}
|
||||||
.sink { _ in
|
.sink { _ in
|
||||||
|
@ -130,8 +131,8 @@ final class SearchViewModel: NSObject {
|
||||||
snapshot.appendSections([.mixed])
|
snapshot.appendSections([.mixed])
|
||||||
|
|
||||||
searchHistories.forEach { searchHistory in
|
searchHistories.forEach { searchHistory in
|
||||||
let containsAccount = scope == Mastodon.API.Search.SearchType.accounts || scope == Mastodon.API.Search.SearchType.default
|
let containsAccount = scope == Mastodon.API.V2.Search.SearchType.accounts || scope == Mastodon.API.V2.Search.SearchType.default
|
||||||
let containsHashTag = scope == Mastodon.API.Search.SearchType.hashtags || scope == Mastodon.API.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 {
|
if let mastodonUser = searchHistory.account, containsAccount {
|
||||||
let item = SearchResultItem.accountObjectID(accountObjectID: mastodonUser.objectID)
|
let item = SearchResultItem.accountObjectID(accountObjectID: mastodonUser.objectID)
|
||||||
snapshot.appendItems([item], toSection: .mixed)
|
snapshot.appendItems([item], toSection: .mixed)
|
||||||
|
@ -142,7 +143,6 @@ final class SearchViewModel: NSObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||||
|
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
@ -161,21 +161,31 @@ final class SearchViewModel: NSObject {
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
requestRecommendAccounts()
|
requestRecommendAccountsV2()
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
if !self.recommendAccounts.isEmpty {
|
if !self.recommendAccounts.isEmpty {
|
||||||
guard let dataSource = self.accountDiffableDataSource else { return }
|
self.applyDataSource()
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, NSManagedObjectID>()
|
|
||||||
snapshot.appendSections([.main])
|
|
||||||
snapshot.appendItems(self.recommendAccounts, toSection: .main)
|
|
||||||
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
|
||||||
}
|
}
|
||||||
} receiveValue: { _ in
|
} receiveValue: { _ in
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
recommendAccountsFallback
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.requestRecommendAccounts()
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if !self.recommendAccounts.isEmpty {
|
||||||
|
self.applyDataSource()
|
||||||
|
}
|
||||||
|
} receiveValue: { _ in
|
||||||
|
}
|
||||||
|
.store(in: &self.disposeBag)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
searchResult
|
searchResult
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] searchResult in
|
.sink { [weak self] searchResult in
|
||||||
|
@ -186,7 +196,7 @@ final class SearchViewModel: NSObject {
|
||||||
snapshot.appendSections([.account])
|
snapshot.appendSections([.account])
|
||||||
let items = accounts.compactMap { SearchResultItem.account(account: $0) }
|
let items = accounts.compactMap { SearchResultItem.account(account: $0) }
|
||||||
snapshot.appendItems(items, toSection: .account)
|
snapshot.appendItems(items, toSection: .account)
|
||||||
if self.searchScope.value == Mastodon.API.Search.SearchType.accounts, !items.isEmpty {
|
if self.searchScope.value == Mastodon.API.V2.Search.SearchType.accounts, !items.isEmpty {
|
||||||
snapshot.appendItems([.bottomLoader], toSection: .account)
|
snapshot.appendItems([.bottomLoader], toSection: .account)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -194,7 +204,7 @@ final class SearchViewModel: NSObject {
|
||||||
snapshot.appendSections([.hashtag])
|
snapshot.appendSections([.hashtag])
|
||||||
let items = tags.compactMap { SearchResultItem.hashtag(tag: $0) }
|
let items = tags.compactMap { SearchResultItem.hashtag(tag: $0) }
|
||||||
snapshot.appendItems(items, toSection: .hashtag)
|
snapshot.appendItems(items, toSection: .hashtag)
|
||||||
if self.searchScope.value == Mastodon.API.Search.SearchType.hashtags, !items.isEmpty {
|
if self.searchScope.value == Mastodon.API.V2.Search.SearchType.hashtags, !items.isEmpty {
|
||||||
snapshot.appendItems([.bottomLoader], toSection: .hashtag)
|
snapshot.appendItems([.bottomLoader], toSection: .hashtag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -227,13 +237,43 @@ final class SearchViewModel: NSObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requestRecommendAccountsV2() -> Future<Void, Error> {
|
||||||
|
Future { promise in
|
||||||
|
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||||
|
promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
||||||
|
.sink { [weak self] completion in
|
||||||
|
switch completion {
|
||||||
|
case .failure(let error):
|
||||||
|
if let apiError = error as? Mastodon.API.Error {
|
||||||
|
if apiError.httpResponseStatus == .notFound {
|
||||||
|
self?.recommendAccountsFallback.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
|
||||||
|
promise(.failure(error))
|
||||||
|
case .finished:
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request success", (#file as NSString).lastPathComponent, #line, #function)
|
||||||
|
promise(.success(()))
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] accounts in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let ids = accounts.value.compactMap({$0.account.id})
|
||||||
|
self.receiveAccounts(ids: ids)
|
||||||
|
}
|
||||||
|
.store(in: &self.disposeBag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func requestRecommendAccounts() -> Future<Void, Error> {
|
func requestRecommendAccounts() -> Future<Void, Error> {
|
||||||
Future { promise in
|
Future { promise in
|
||||||
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||||
promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing)))
|
promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.context.apiService.recommendAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
self.context.apiService.suggestionAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
||||||
.sink { completion in
|
.sink { completion in
|
||||||
switch completion {
|
switch completion {
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
|
@ -246,27 +286,47 @@ final class SearchViewModel: NSObject {
|
||||||
} receiveValue: { [weak self] accounts in
|
} receiveValue: { [weak self] accounts in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let ids = accounts.value.compactMap({$0.id})
|
let ids = accounts.value.compactMap({$0.id})
|
||||||
let userFetchRequest = MastodonUser.sortedFetchRequest
|
self.receiveAccounts(ids: ids)
|
||||||
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
|
|
||||||
let mastodonUsers: [MastodonUser]? = {
|
|
||||||
let userFetchRequest = MastodonUser.sortedFetchRequest
|
|
||||||
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
|
|
||||||
userFetchRequest.returnsObjectsAsFaults = false
|
|
||||||
do {
|
|
||||||
return try self.context.managedObjectContext.fetch(userFetchRequest)
|
|
||||||
} catch {
|
|
||||||
assertionFailure(error.localizedDescription)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if let users = mastodonUsers {
|
|
||||||
self.recommendAccounts = users.map(\.objectID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.store(in: &self.disposeBag)
|
.store(in: &self.disposeBag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyDataSource() {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let dataSource = self.accountDiffableDataSource else { return }
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, NSManagedObjectID>()
|
||||||
|
snapshot.appendSections([.main])
|
||||||
|
snapshot.appendItems(self.recommendAccounts, toSection: .main)
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiveAccounts(ids: [String]) {
|
||||||
|
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let userFetchRequest = MastodonUser.sortedFetchRequest
|
||||||
|
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
|
||||||
|
let mastodonUsers: [MastodonUser]? = {
|
||||||
|
let userFetchRequest = MastodonUser.sortedFetchRequest
|
||||||
|
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
|
||||||
|
userFetchRequest.returnsObjectsAsFaults = false
|
||||||
|
do {
|
||||||
|
return try self.context.managedObjectContext.fetch(userFetchRequest)
|
||||||
|
} catch {
|
||||||
|
assertionFailure(error.localizedDescription)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if let users = mastodonUsers {
|
||||||
|
let sortedUsers = users.sorted { (user1, user2) -> Bool in
|
||||||
|
(ids.firstIndex(of: user1.id) ?? 0) < (ids.firstIndex(of: user2.id) ?? 0)
|
||||||
|
}
|
||||||
|
recommendAccounts = sortedUsers.map(\.objectID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func accountCollectionViewItemDidSelected(mastodonUser: MastodonUser, from: UIViewController) {
|
func accountCollectionViewItemDidSelected(mastodonUser: MastodonUser, from: UIViewController) {
|
||||||
let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser)
|
let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
//
|
||||||
|
// SuggestionAccountCollectionViewCell.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by sxiaojian on 2021/4/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreDataStack
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class SuggestionAccountCollectionViewCell: UICollectionViewCell {
|
||||||
|
let imageView: UIImageView = {
|
||||||
|
let imageView = UIImageView()
|
||||||
|
imageView.tintColor = Asset.Colors.Label.tertiary.color
|
||||||
|
imageView.layer.cornerRadius = 4
|
||||||
|
imageView.clipsToBounds = true
|
||||||
|
imageView.image = UIImage.placeholder(color: .systemFill)
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
func configAsPlaceHolder() {
|
||||||
|
imageView.tintColor = Asset.Colors.Label.tertiary.color
|
||||||
|
imageView.image = UIImage.placeholder(color: .systemFill)
|
||||||
|
}
|
||||||
|
|
||||||
|
func config(with mastodonUser: MastodonUser) {
|
||||||
|
imageView.af.setImage(
|
||||||
|
withURL: URL(string: mastodonUser.avatar)!,
|
||||||
|
placeholderImage: UIImage.placeholder(color: .systemFill),
|
||||||
|
imageTransition: .crossDissolve(0.2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
configure()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
configure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SuggestionAccountCollectionViewCell {
|
||||||
|
private func configure() {
|
||||||
|
contentView.addSubview(imageView)
|
||||||
|
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||||
|
imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||||
|
imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||||
|
imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,213 @@
|
||||||
|
//
|
||||||
|
// SuggestionAccountViewController.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by sxiaojian on 2021/4/21.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class SuggestionAccountViewController: UIViewController, NeedsDependency {
|
||||||
|
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||||
|
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||||
|
|
||||||
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
var viewModel: SuggestionAccountViewModel!
|
||||||
|
|
||||||
|
let tableView: UITableView = {
|
||||||
|
let tableView = ControlContainableTableView()
|
||||||
|
tableView.register(SuggestionAccountTableViewCell.self, forCellReuseIdentifier: String(describing: SuggestionAccountTableViewCell.self))
|
||||||
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
|
tableView.tableFooterView = UIView()
|
||||||
|
tableView.separatorStyle = .singleLine
|
||||||
|
tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
|
||||||
|
return tableView
|
||||||
|
}()
|
||||||
|
|
||||||
|
lazy var tableHeader: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||||
|
view.frame = CGRect(origin: .zero, size: CGSize(width: tableView.frame.width, height: 156))
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
let followExplainLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = L10n.Scene.SuggestionAccount.followExplain
|
||||||
|
label.textColor = Asset.Colors.Label.primary.color
|
||||||
|
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular))
|
||||||
|
label.numberOfLines = 0
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
let selectedCollectionView: UICollectionView = {
|
||||||
|
let flowLayout = UICollectionViewFlowLayout()
|
||||||
|
flowLayout.scrollDirection = .horizontal
|
||||||
|
let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
|
||||||
|
view.register(SuggestionAccountCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SuggestionAccountCollectionViewCell.self))
|
||||||
|
view.backgroundColor = .clear
|
||||||
|
view.showsHorizontalScrollIndicator = false
|
||||||
|
view.showsVerticalScrollIndicator = false
|
||||||
|
view.layer.masksToBounds = false
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", (#file as NSString).lastPathComponent, #line, #function)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SuggestionAccountViewController {
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = Asset.Colors.Background.systemBackground.color
|
||||||
|
title = L10n.Scene.SuggestionAccount.title
|
||||||
|
navigationItem.rightBarButtonItem
|
||||||
|
= UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.done,
|
||||||
|
target: self,
|
||||||
|
action: #selector(SuggestionAccountViewController.doneButtonDidClick(_:)))
|
||||||
|
|
||||||
|
tableView.delegate = self
|
||||||
|
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.diffableDataSource = RecommendAccountSection.tableViewDiffableDataSource(
|
||||||
|
for: tableView,
|
||||||
|
managedObjectContext: context.managedObjectContext,
|
||||||
|
viewModel: viewModel,
|
||||||
|
delegate: self
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.collectionDiffableDataSource = SelectedAccountSection.collectionViewDiffableDataSource(for: selectedCollectionView, managedObjectContext: context.managedObjectContext)
|
||||||
|
|
||||||
|
viewModel.accounts
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] accounts in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.setupHeader(accounts: accounts)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
tableView.deselectRow(with: transitionCoordinator, animated: animated)
|
||||||
|
viewModel.checkAccountsFollowState()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillLayoutSubviews() {
|
||||||
|
super.viewWillLayoutSubviews()
|
||||||
|
let avatarImageViewHeight: Double = 56
|
||||||
|
let avatarImageViewCount = Int(floor((Double(view.frame.width) - 20) / (avatarImageViewHeight + 15)))
|
||||||
|
viewModel.headerPlaceholderCount.value = avatarImageViewCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupHeader(accounts: [NSManagedObjectID]) {
|
||||||
|
if accounts.isEmpty {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
followExplainLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
tableHeader.addSubview(followExplainLabel)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
followExplainLabel.topAnchor.constraint(equalTo: tableHeader.topAnchor, constant: 20),
|
||||||
|
followExplainLabel.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20),
|
||||||
|
tableHeader.trailingAnchor.constraint(equalTo: followExplainLabel.trailingAnchor, constant: 20),
|
||||||
|
])
|
||||||
|
|
||||||
|
selectedCollectionView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
tableHeader.addSubview(selectedCollectionView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
selectedCollectionView.frameLayoutGuide.topAnchor.constraint(equalTo: followExplainLabel.topAnchor, constant: 20),
|
||||||
|
selectedCollectionView.frameLayoutGuide.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20),
|
||||||
|
selectedCollectionView.frameLayoutGuide.trailingAnchor.constraint(equalTo: tableHeader.trailingAnchor),
|
||||||
|
selectedCollectionView.frameLayoutGuide.bottomAnchor.constraint(equalTo: tableHeader.bottomAnchor),
|
||||||
|
])
|
||||||
|
selectedCollectionView.delegate = self
|
||||||
|
|
||||||
|
tableView.tableHeaderView = tableHeader
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SuggestionAccountViewController: UICollectionViewDelegateFlowLayout {
|
||||||
|
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
|
||||||
|
15
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||||||
|
CGSize(width: 56, height: 56)
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
guard let diffableDataSource = viewModel.collectionDiffableDataSource else { return }
|
||||||
|
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||||
|
switch item {
|
||||||
|
case .accountObjectID(let accountObjectID):
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SuggestionAccountViewController: UITableViewDelegate {
|
||||||
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||||
|
guard let objectID = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||||
|
let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser
|
||||||
|
let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegate {
|
||||||
|
func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell) {
|
||||||
|
let selected = !viewModel.selectedAccounts.value.contains(objectID)
|
||||||
|
cell.startAnimating()
|
||||||
|
viewModel.followAction(objectID: objectID)?
|
||||||
|
.sink(receiveCompletion: { [weak self] completion in
|
||||||
|
guard let self = self else { return }
|
||||||
|
cell.stopAnimating()
|
||||||
|
switch completion {
|
||||||
|
case .failure(let error):
|
||||||
|
os_log("%{public}s[%{public}ld], %{public}s: follow failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
|
||||||
|
case .finished:
|
||||||
|
var selectedAccounts = self.viewModel.selectedAccounts.value
|
||||||
|
if selected {
|
||||||
|
selectedAccounts.append(objectID)
|
||||||
|
} else {
|
||||||
|
selectedAccounts.removeAll { $0 == objectID }
|
||||||
|
}
|
||||||
|
cell.button.isSelected = selected
|
||||||
|
self.viewModel.selectedAccounts.value = selectedAccounts
|
||||||
|
}
|
||||||
|
}, receiveValue: { _ in
|
||||||
|
})
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SuggestionAccountViewController {
|
||||||
|
@objc func doneButtonDidClick(_ sender: UIButton) {
|
||||||
|
dismiss(animated: true, completion: nil)
|
||||||
|
if viewModel.selectedAccounts.value.count > 0 {
|
||||||
|
viewModel.delegate?.homeTimelineNeedRefresh.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,222 @@
|
||||||
|
//
|
||||||
|
// SuggestionAccountViewModel.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by sxiaojian on 2021/4/21.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
import GameplayKit
|
||||||
|
import MastodonSDK
|
||||||
|
import os.log
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
protocol SuggestionAccountViewModelDelegate: AnyObject {
|
||||||
|
var homeTimelineNeedRefresh: PassthroughSubject<Void, Never> { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SuggestionAccountViewModel: NSObject {
|
||||||
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// input
|
||||||
|
let context: AppContext
|
||||||
|
|
||||||
|
let currentMastodonUser = CurrentValueSubject<MastodonUser?, Never>(nil)
|
||||||
|
weak var delegate: SuggestionAccountViewModelDelegate?
|
||||||
|
// output
|
||||||
|
let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([])
|
||||||
|
var selectedAccounts = CurrentValueSubject<[NSManagedObjectID], Never>([])
|
||||||
|
|
||||||
|
var headerPlaceholderCount = CurrentValueSubject<Int?, Never>(nil)
|
||||||
|
var suggestionAccountsFallback = PassthroughSubject<Void, Never>()
|
||||||
|
|
||||||
|
var viewWillAppear = PassthroughSubject<Void, Never>()
|
||||||
|
|
||||||
|
var diffableDataSource: UITableViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID>? {
|
||||||
|
didSet(value) {
|
||||||
|
if !accounts.value.isEmpty {
|
||||||
|
applyTableViewDataSource(accounts: accounts.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var collectionDiffableDataSource: UICollectionViewDiffableDataSource<SelectedAccountSection, SelectedAccountItem>?
|
||||||
|
|
||||||
|
init(context: AppContext, accounts: [NSManagedObjectID]? = nil) {
|
||||||
|
self.context = context
|
||||||
|
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
Publishers.CombineLatest(self.accounts,self.selectedAccounts)
|
||||||
|
.sink { [weak self] accounts,selectedAccounts in
|
||||||
|
self?.applyTableViewDataSource(accounts: accounts)
|
||||||
|
self?.applySelectedCollectionViewDataSource(accounts: selectedAccounts)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
Publishers.CombineLatest(self.selectedAccounts,self.headerPlaceholderCount)
|
||||||
|
.sink { [weak self] selectedAccount,count in
|
||||||
|
self?.applySelectedCollectionViewDataSource(accounts: selectedAccount)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
viewWillAppear
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.checkAccountsFollowState()
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
if let accounts = accounts {
|
||||||
|
self.accounts.value = accounts
|
||||||
|
}
|
||||||
|
|
||||||
|
context.authenticationService.activeMastodonAuthentication
|
||||||
|
.sink { [weak self] activeMastodonAuthentication in
|
||||||
|
guard let self = self else { return }
|
||||||
|
guard let activeMastodonAuthentication = activeMastodonAuthentication else {
|
||||||
|
self.currentMastodonUser.value = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.currentMastodonUser.value = activeMastodonAuthentication.user
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
if accounts == nil || (accounts ?? []).isEmpty {
|
||||||
|
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
|
||||||
|
|
||||||
|
context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
||||||
|
.sink { [weak self] completion in
|
||||||
|
switch completion {
|
||||||
|
case .failure(let error):
|
||||||
|
if let apiError = error as? Mastodon.API.Error {
|
||||||
|
if apiError.httpResponseStatus == .notFound {
|
||||||
|
self?.suggestionAccountsFallback.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
os_log("%{public}s[%{public}ld], %{public}s: fetch recommendAccountV2 failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
|
||||||
|
case .finished:
|
||||||
|
// handle isFetchingLatestTimeline in fetch controller delegate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] response in
|
||||||
|
let ids = response.value.map(\.account.id)
|
||||||
|
self?.receiveAccounts(ids: ids)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
suggestionAccountsFallback
|
||||||
|
.sink(receiveValue: { [weak self] _ in
|
||||||
|
self?.requestSuggestionAccount()
|
||||||
|
})
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestSuggestionAccount() {
|
||||||
|
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
|
||||||
|
context.apiService.suggestionAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
||||||
|
.sink { completion in
|
||||||
|
switch completion {
|
||||||
|
case .failure(let error):
|
||||||
|
os_log("%{public}s[%{public}ld], %{public}s: fetch recommendAccount failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
|
||||||
|
case .finished:
|
||||||
|
// handle isFetchingLatestTimeline in fetch controller delegate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] response in
|
||||||
|
let ids = response.value.map(\.id)
|
||||||
|
self?.receiveAccounts(ids: ids)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyTableViewDataSource(accounts: [NSManagedObjectID]) {
|
||||||
|
guard let dataSource = diffableDataSource else { return }
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<RecommendAccountSection, NSManagedObjectID>()
|
||||||
|
snapshot.appendSections([.main])
|
||||||
|
snapshot.appendItems(accounts, toSection: .main)
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySelectedCollectionViewDataSource(accounts: [NSManagedObjectID]) {
|
||||||
|
guard let count = headerPlaceholderCount.value else { return }
|
||||||
|
guard let dataSource = collectionDiffableDataSource else { return }
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<SelectedAccountSection, SelectedAccountItem>()
|
||||||
|
snapshot.appendSections([.main])
|
||||||
|
let placeholderCount = count - accounts.count
|
||||||
|
let accountItems = accounts.map { SelectedAccountItem.accountObjectID(accountObjectID: $0) }
|
||||||
|
snapshot.appendItems(accountItems, toSection: .main)
|
||||||
|
|
||||||
|
if placeholderCount > 0 {
|
||||||
|
for _ in 0 ..< placeholderCount {
|
||||||
|
snapshot.appendItems([SelectedAccountItem.placeHolder(uuid: UUID())], toSection: .main)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiveAccounts(ids: [String]) {
|
||||||
|
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let userFetchRequest = MastodonUser.sortedFetchRequest
|
||||||
|
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
|
||||||
|
let mastodonUsers: [MastodonUser]? = {
|
||||||
|
let userFetchRequest = MastodonUser.sortedFetchRequest
|
||||||
|
userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids)
|
||||||
|
userFetchRequest.returnsObjectsAsFaults = false
|
||||||
|
do {
|
||||||
|
return try self.context.managedObjectContext.fetch(userFetchRequest)
|
||||||
|
} catch {
|
||||||
|
assertionFailure(error.localizedDescription)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if let users = mastodonUsers {
|
||||||
|
let sortedUsers = users.sorted { (user1, user2) -> Bool in
|
||||||
|
(ids.firstIndex(of: user1.id) ?? 0) < (ids.firstIndex(of: user2.id) ?? 0)
|
||||||
|
}
|
||||||
|
accounts.value = sortedUsers.map(\.objectID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func followAction(objectID: NSManagedObjectID) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error>? {
|
||||||
|
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return nil }
|
||||||
|
|
||||||
|
let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser
|
||||||
|
return context.apiService.toggleFollow(
|
||||||
|
for: mastodonUser,
|
||||||
|
activeMastodonAuthenticationBox: activeMastodonAuthenticationBox,
|
||||||
|
needFeedback: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkAccountsFollowState() {
|
||||||
|
guard let currentMastodonUser = currentMastodonUser.value else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let users: [MastodonUser] = accounts.value.compactMap {
|
||||||
|
guard let user = context.managedObjectContext.object(with: $0) as? MastodonUser else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let isBlock = user.blockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
|
||||||
|
let isDomainBlock = user.domainBlockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
|
||||||
|
if isBlock || isDomainBlock {
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accounts.value = users.map(\.objectID)
|
||||||
|
|
||||||
|
let followingUsers = users.filter { user -> Bool in
|
||||||
|
let isFollowing = user.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
|
||||||
|
let isPending = user.followRequestedBy.flatMap { $0.contains(currentMastodonUser) } ?? false
|
||||||
|
return isFollowing || isPending
|
||||||
|
}.map(\.objectID)
|
||||||
|
|
||||||
|
selectedAccounts.value = followingUsers
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,191 @@
|
||||||
|
//
|
||||||
|
// SuggestionAccountTableViewCell.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by sxiaojian on 2021/4/21.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
import Foundation
|
||||||
|
import MastodonSDK
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
protocol SuggestionAccountTableViewCellDelegate: AnyObject {
|
||||||
|
func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell)
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SuggestionAccountTableViewCell: UITableViewCell {
|
||||||
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
weak var delegate: SuggestionAccountTableViewCellDelegate?
|
||||||
|
|
||||||
|
let _imageView: UIImageView = {
|
||||||
|
let imageView = UIImageView()
|
||||||
|
imageView.tintColor = Asset.Colors.Label.primary.color
|
||||||
|
imageView.layer.cornerRadius = 4
|
||||||
|
imageView.clipsToBounds = true
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
let titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.textColor = Asset.Colors.brandBlue.color
|
||||||
|
label.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
label.lineBreakMode = .byTruncatingTail
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
let subTitleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.textColor = Asset.Colors.Label.secondary.color
|
||||||
|
label.font = .preferredFont(forTextStyle: .body)
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
let buttonContainer: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = .clear
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
let button: HighlightDimmableButton = {
|
||||||
|
let button = HighlightDimmableButton(type: .custom)
|
||||||
|
if let plusImage = UIImage(systemName: "plus.circle", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))?.withRenderingMode(.alwaysTemplate) {
|
||||||
|
button.setImage(plusImage, for: .normal)
|
||||||
|
}
|
||||||
|
if let minusImage = UIImage(systemName: "minus.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))?.withRenderingMode(.alwaysTemplate) {
|
||||||
|
button.setImage(minusImage, for: .selected)
|
||||||
|
}
|
||||||
|
return button
|
||||||
|
}()
|
||||||
|
|
||||||
|
let activityIndicatorView: UIActivityIndicatorView = {
|
||||||
|
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
|
||||||
|
activityIndicatorView.hidesWhenStopped = true
|
||||||
|
return activityIndicatorView
|
||||||
|
}()
|
||||||
|
|
||||||
|
override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
|
_imageView.af.cancelImageRequest()
|
||||||
|
_imageView.image = nil
|
||||||
|
disposeBag.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
configure()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
configure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SuggestionAccountTableViewCell {
|
||||||
|
private func configure() {
|
||||||
|
backgroundColor = .clear
|
||||||
|
|
||||||
|
let containerStackView = UIStackView()
|
||||||
|
containerStackView.axis = .horizontal
|
||||||
|
containerStackView.distribution = .fill
|
||||||
|
containerStackView.spacing = 12
|
||||||
|
containerStackView.layoutMargins = UIEdgeInsets(top: 12, left: 21, bottom: 12, right: 12)
|
||||||
|
containerStackView.isLayoutMarginsRelativeArrangement = true
|
||||||
|
containerStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
contentView.addSubview(containerStackView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||||
|
containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||||
|
containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||||
|
containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
_imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
containerStackView.addArrangedSubview(_imageView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
_imageView.widthAnchor.constraint(equalToConstant: 42).priority(.required - 1),
|
||||||
|
_imageView.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1),
|
||||||
|
])
|
||||||
|
|
||||||
|
let textStackView = UIStackView()
|
||||||
|
textStackView.axis = .vertical
|
||||||
|
textStackView.distribution = .fill
|
||||||
|
textStackView.alignment = .leading
|
||||||
|
textStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
textStackView.addArrangedSubview(titleLabel)
|
||||||
|
subTitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
textStackView.addArrangedSubview(subTitleLabel)
|
||||||
|
subTitleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical)
|
||||||
|
|
||||||
|
containerStackView.addArrangedSubview(textStackView)
|
||||||
|
textStackView.setContentHuggingPriority(.defaultLow - 1, for: .horizontal)
|
||||||
|
|
||||||
|
buttonContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
containerStackView.addArrangedSubview(buttonContainer)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
buttonContainer.widthAnchor.constraint(equalToConstant: 24).priority(.required - 1),
|
||||||
|
buttonContainer.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1),
|
||||||
|
])
|
||||||
|
buttonContainer.setContentHuggingPriority(.required - 1, for: .horizontal)
|
||||||
|
|
||||||
|
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
button.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
buttonContainer.addSubview(button)
|
||||||
|
buttonContainer.addSubview(activityIndicatorView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
buttonContainer.centerXAnchor.constraint(equalTo: activityIndicatorView.centerXAnchor),
|
||||||
|
buttonContainer.centerYAnchor.constraint(equalTo: activityIndicatorView.centerYAnchor),
|
||||||
|
buttonContainer.centerXAnchor.constraint(equalTo: button.centerXAnchor),
|
||||||
|
buttonContainer.centerYAnchor.constraint(equalTo: button.centerYAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func config(with account: MastodonUser, isSelected: Bool) {
|
||||||
|
if let url = account.avatarImageURL() {
|
||||||
|
_imageView.af.setImage(
|
||||||
|
withURL: url,
|
||||||
|
placeholderImage: UIImage.placeholder(color: .systemFill),
|
||||||
|
imageTransition: .crossDissolve(0.2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName
|
||||||
|
subTitleLabel.text = account.acct
|
||||||
|
button.isSelected = isSelected
|
||||||
|
button.publisher(for: .touchUpInside)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.delegate?.accountButtonPressed(objectID: account.objectID, cell: self)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
button.publisher(for: \.isSelected)
|
||||||
|
.sink { [weak self] isSelected in
|
||||||
|
if isSelected {
|
||||||
|
self?.button.tintColor = Asset.Colors.danger.color
|
||||||
|
} else {
|
||||||
|
self?.button.tintColor = Asset.Colors.Label.secondary.color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
activityIndicatorView.publisher(for: \.isHidden)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] isHidden in
|
||||||
|
self?.button.isHidden = !isHidden
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startAnimating() {
|
||||||
|
activityIndicatorView.isHidden = false
|
||||||
|
activityIndicatorView.startAnimating()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopAnimating() {
|
||||||
|
activityIndicatorView.stopAnimating()
|
||||||
|
activityIndicatorView.isHidden = true
|
||||||
|
}
|
||||||
|
}
|
|
@ -24,10 +24,15 @@ extension APIService {
|
||||||
/// - Returns: publisher for `Relationship`
|
/// - Returns: publisher for `Relationship`
|
||||||
func toggleFollow(
|
func toggleFollow(
|
||||||
for mastodonUser: MastodonUser,
|
for mastodonUser: MastodonUser,
|
||||||
activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
|
||||||
|
needFeedback: Bool
|
||||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
|
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
|
||||||
let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
|
var impactFeedbackGenerator: UIImpactFeedbackGenerator?
|
||||||
let notificationFeedbackGenerator = UINotificationFeedbackGenerator()
|
var notificationFeedbackGenerator: UINotificationFeedbackGenerator?
|
||||||
|
if needFeedback {
|
||||||
|
impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
|
||||||
|
notificationFeedbackGenerator = UINotificationFeedbackGenerator()
|
||||||
|
}
|
||||||
|
|
||||||
return followUpdateLocal(
|
return followUpdateLocal(
|
||||||
mastodonUserObjectID: mastodonUser.objectID,
|
mastodonUserObjectID: mastodonUser.objectID,
|
||||||
|
@ -35,9 +40,9 @@ extension APIService {
|
||||||
)
|
)
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.handleEvents { _ in
|
.handleEvents { _ in
|
||||||
impactFeedbackGenerator.prepare()
|
impactFeedbackGenerator?.prepare()
|
||||||
} receiveOutput: { _ in
|
} receiveOutput: { _ in
|
||||||
impactFeedbackGenerator.impactOccurred()
|
impactFeedbackGenerator?.impactOccurred()
|
||||||
} receiveCompletion: { completion in
|
} receiveCompletion: { completion in
|
||||||
switch completion {
|
switch completion {
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
|
@ -74,13 +79,13 @@ extension APIService {
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function)
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
} receiveValue: { _ in
|
} receiveValue: { _ in
|
||||||
// do nothing
|
// do nothing
|
||||||
notificationFeedbackGenerator.prepare()
|
notificationFeedbackGenerator?.prepare()
|
||||||
notificationFeedbackGenerator.notificationOccurred(.error)
|
notificationFeedbackGenerator?.notificationOccurred(.error)
|
||||||
}
|
}
|
||||||
.store(in: &self.disposeBag)
|
.store(in: &self.disposeBag)
|
||||||
|
|
||||||
case .finished:
|
case .finished:
|
||||||
notificationFeedbackGenerator.notificationOccurred(.success)
|
notificationFeedbackGenerator?.notificationOccurred(.success)
|
||||||
os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function)
|
os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -13,7 +13,7 @@ import CoreDataStack
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
extension APIService {
|
extension APIService {
|
||||||
func recommendAccount(
|
func suggestionAccount(
|
||||||
domain: String,
|
domain: String,
|
||||||
query: Mastodon.API.Suggestions.Query?,
|
query: Mastodon.API.Suggestions.Query?,
|
||||||
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
||||||
|
@ -44,6 +44,38 @@ extension APIService {
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func suggestionAccountV2(
|
||||||
|
domain: String,
|
||||||
|
query: Mastodon.API.Suggestions.Query?,
|
||||||
|
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
||||||
|
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]>, Error> {
|
||||||
|
let authorization = mastodonAuthenticationBox.userAuthorization
|
||||||
|
|
||||||
|
return Mastodon.API.V2.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization)
|
||||||
|
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]>, Error> in
|
||||||
|
let log = OSLog.api
|
||||||
|
return self.backgroundManagedObjectContext.performChanges {
|
||||||
|
response.value.forEach { suggestionAccount in
|
||||||
|
let user = suggestionAccount.account
|
||||||
|
let (mastodonUser,isCreated) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: user, userCache: nil, networkDate: Date(), log: log)
|
||||||
|
let flag = isCreated ? "+" : "-"
|
||||||
|
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)%s", (#file as NSString).lastPathComponent, #line, #function, flag, mastodonUser.id, mastodonUser.username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.setFailureType(to: Error.self)
|
||||||
|
.tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]> in
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
return response
|
||||||
|
case .failure(let error):
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
func recommendTrends(
|
func recommendTrends(
|
||||||
domain: String,
|
domain: String,
|
||||||
query: Mastodon.API.Trends.Query?
|
query: Mastodon.API.Trends.Query?
|
||||||
|
|
|
@ -13,11 +13,11 @@ extension APIService {
|
||||||
|
|
||||||
func search(
|
func search(
|
||||||
domain: String,
|
domain: String,
|
||||||
query: Mastodon.API.Search.Query,
|
query: Mastodon.API.V2.Search.Query,
|
||||||
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
|
||||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> {
|
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> {
|
||||||
let authorization = mastodonAuthenticationBox.userAuthorization
|
let authorization = mastodonAuthenticationBox.userAuthorization
|
||||||
|
|
||||||
return Mastodon.API.Search.search(session: session, domain: domain, query: query, authorization: authorization)
|
return Mastodon.API.V2.Search.search(session: session, domain: domain, query: query, authorization: authorization)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension Mastodon.API.Search {
|
extension Mastodon.API.V2.Search {
|
||||||
static func searchURL(domain: String) -> URL {
|
static func searchURL(domain: String) -> URL {
|
||||||
Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("search")
|
Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("search")
|
||||||
}
|
}
|
||||||
|
@ -32,7 +32,7 @@ extension Mastodon.API.Search {
|
||||||
public static func search(
|
public static func search(
|
||||||
session: URLSession,
|
session: URLSession,
|
||||||
domain: String,
|
domain: String,
|
||||||
query: Mastodon.API.Search.Query,
|
query: Mastodon.API.V2.Search.Query,
|
||||||
authorization: Mastodon.API.OAuth.Authorization
|
authorization: Mastodon.API.OAuth.Authorization
|
||||||
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> {
|
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> {
|
||||||
let request = Mastodon.API.get(
|
let request = Mastodon.API.get(
|
||||||
|
@ -49,7 +49,7 @@ extension Mastodon.API.Search {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Mastodon.API.Search {
|
extension Mastodon.API.V2.Search {
|
||||||
|
|
||||||
public struct Query: Codable, GetQuery {
|
public struct Query: Codable, GetQuery {
|
||||||
public init(q: String,
|
public init(q: String,
|
||||||
|
@ -105,7 +105,7 @@ extension Mastodon.API.Search {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension Mastodon.API.Search {
|
public extension Mastodon.API.V2.Search {
|
||||||
enum SearchType: String, Codable {
|
enum SearchType: String, Codable {
|
||||||
case accounts
|
case accounts
|
||||||
case hashtags
|
case hashtags
|
|
@ -0,0 +1,41 @@
|
||||||
|
//
|
||||||
|
// Mastodon+API+V2+Suggestions.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by sxiaojian on 2021/4/20.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Mastodon.API.V2.Suggestions {
|
||||||
|
static func suggestionsURL(domain: String) -> URL {
|
||||||
|
Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("suggestions")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follow suggestions, No document for now
|
||||||
|
/// - Parameters:
|
||||||
|
/// - session: `URLSession`
|
||||||
|
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||||
|
/// - query: query
|
||||||
|
/// - authorization: User token.
|
||||||
|
/// - Returns: `AnyPublisher` contains `AccountsSuggestion` nested in the response
|
||||||
|
public static func get(
|
||||||
|
session: URLSession,
|
||||||
|
domain: String,
|
||||||
|
query: Mastodon.API.Suggestions.Query?,
|
||||||
|
authorization: Mastodon.API.OAuth.Authorization
|
||||||
|
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]>, Error> {
|
||||||
|
let request = Mastodon.API.get(
|
||||||
|
url: suggestionsURL(domain: domain),
|
||||||
|
query: query,
|
||||||
|
authorization: authorization
|
||||||
|
)
|
||||||
|
return session.dataTaskPublisher(for: request)
|
||||||
|
.tryMap { data, response in
|
||||||
|
let value = try Mastodon.API.decode(type: [Mastodon.Entity.V2.SuggestionAccount].self, from: data, response: response)
|
||||||
|
return Mastodon.Response.Content(value: value, response: response)
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
}
|
|
@ -99,6 +99,7 @@ extension Mastodon.API {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Mastodon.API {
|
extension Mastodon.API {
|
||||||
|
public enum V2 { }
|
||||||
public enum Account { }
|
public enum Account { }
|
||||||
public enum App { }
|
public enum App { }
|
||||||
public enum CustomEmojis { }
|
public enum CustomEmojis { }
|
||||||
|
@ -111,13 +112,17 @@ extension Mastodon.API {
|
||||||
public enum Reblog { }
|
public enum Reblog { }
|
||||||
public enum Statuses { }
|
public enum Statuses { }
|
||||||
public enum Timeline { }
|
public enum Timeline { }
|
||||||
public enum Search { }
|
|
||||||
public enum Trends { }
|
public enum Trends { }
|
||||||
public enum Suggestions { }
|
public enum Suggestions { }
|
||||||
public enum Notifications { }
|
public enum Notifications { }
|
||||||
public enum Subscriptions { }
|
public enum Subscriptions { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Mastodon.API.V2 {
|
||||||
|
public enum Search { }
|
||||||
|
public enum Suggestions { }
|
||||||
|
}
|
||||||
|
|
||||||
extension Mastodon.API {
|
extension Mastodon.API {
|
||||||
|
|
||||||
static func get(
|
static func get(
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
//
|
||||||
|
// Mastodon+Entity+Suggestion.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by sxiaojian on 2021/4/20.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Mastodon.Entity.V2 {
|
||||||
|
|
||||||
|
public struct SuggestionAccount: Codable {
|
||||||
|
|
||||||
|
public let source: String
|
||||||
|
public let account: Mastodon.Entity.Account
|
||||||
|
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case source
|
||||||
|
case account
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue