diff --git a/Localization/app.json b/Localization/app.json index 812a801ac..6d96fd5bd 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -263,6 +263,19 @@ "searchBar": { "placeholder": "Search hashtags and users", "cancel": "Cancel" + }, + "recommend": { + "buttonText": "See All", + "hash_tag": { + "title": "Trending in your timeline", + "description": "Hashtags that are getting quite a bit of attention among people you follow", + "people_talking": "%s people are talking" + }, + "accounts": { + "title": "Accounts you might like", + "description": "Except for Sam, you will not like his account.", + "follow": "Follow" + } } } } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 9e86ad664..a2ab8dd41 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -30,7 +30,7 @@ 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */; }; 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAB925CB9B0500C9ED86 /* UIView.swift */; }; 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */; }; - 2D34D9CB261489930081BFC0 /* SearchViewController+recomendView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9CA261489930081BFC0 /* SearchViewController+recomendView.swift */; }; + 2D34D9CB261489930081BFC0 /* SearchViewController+RecomendView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9CA261489930081BFC0 /* SearchViewController+RecomendView.swift */; }; 2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */; }; 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9DA261494120081BFC0 /* APIService+Search.swift */; }; 2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */; }; @@ -94,6 +94,10 @@ 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; }; 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; }; 2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; }; + 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */; }; + 2DE0FAC12615F04D00CDF649 /* RecomendHashTagSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC02615F04D00CDF649 /* RecomendHashTagSection.swift */; }; + 2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */; }; + 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */; }; 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF123A625C3B0210020F248 /* ActiveLabel.swift */; }; 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */; }; 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */; }; @@ -104,6 +108,9 @@ 5D0393902612D259007FE196 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D03938F2612D259007FE196 /* WebViewController.swift */; }; 5D0393962612D266007FE196 /* WebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0393952612D266007FE196 /* WebViewModel.swift */; }; 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; + 5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */; }; + 5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */; }; + 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */; }; 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */; }; 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */; }; 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */; }; @@ -367,7 +374,7 @@ 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = ""; }; 2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; - 2D34D9CA261489930081BFC0 /* SearchViewController+recomendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+recomendView.swift"; sourceTree = ""; }; + 2D34D9CA261489930081BFC0 /* SearchViewController+RecomendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+RecomendView.swift"; sourceTree = ""; }; 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Recommend.swift"; sourceTree = ""; }; 2D34D9DA261494120081BFC0 /* APIService+Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Search.swift"; sourceTree = ""; }; 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendTagsCollectionViewCell.swift; sourceTree = ""; }; @@ -428,6 +435,10 @@ 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = ""; }; 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; 2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; + 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendCollectionHeader.swift; sourceTree = ""; }; + 2DE0FAC02615F04D00CDF649 /* RecomendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecomendHashTagSection.swift; sourceTree = ""; }; + 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendAccountsCollectionViewCell.swift; sourceTree = ""; }; + 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendAccountSection.swift; sourceTree = ""; }; 2DF123A625C3B0210020F248 /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = ""; }; 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusProviderFacade.swift; sourceTree = ""; }; 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+StatusTableViewCellDelegate.swift"; sourceTree = ""; }; @@ -441,6 +452,9 @@ 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = ""; }; 5D03938F2612D259007FE196 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; }; 5D0393952612D266007FE196 /* WebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewModel.swift; sourceTree = ""; }; + 5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Account.swift"; sourceTree = ""; }; + 5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Tag.swift"; sourceTree = ""; }; + 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+History.swift"; sourceTree = ""; }; 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViedeoPlaybackService.swift; sourceTree = ""; }; 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayer.swift; sourceTree = ""; }; @@ -772,6 +786,7 @@ isa = PBXGroup; children = ( 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */, + 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */, ); path = CollectionViewCell; sourceTree = ""; @@ -926,6 +941,8 @@ DB4481C525EE2ADA00BEFB67 /* PollSection.swift */, DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */, DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */, + 2DE0FAC02615F04D00CDF649 /* RecomendHashTagSection.swift */, + 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */, ); @@ -991,6 +1008,14 @@ path = Decoration; sourceTree = ""; }; + 2DE0FAC62615F5D200CDF649 /* View */ = { + isa = PBXGroup; + children = ( + 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */, + ); + path = View; + sourceTree = ""; + }; 2DF75BB725D1473400694EC8 /* Stack */ = { isa = PBXGroup; children = ( @@ -1256,6 +1281,9 @@ isa = PBXGroup; children = ( DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */, + 5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */, + 5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */, + 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */, 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */, ); path = MastodonSDK; @@ -1480,8 +1508,9 @@ DB9D6BEE25E4F5370051B173 /* Search */ = { isa = PBXGroup; children = ( + 2DE0FAC62615F5D200CDF649 /* View */, DB9D6BE825E4F5340051B173 /* SearchViewController.swift */, - 2D34D9CA261489930081BFC0 /* SearchViewController+recomendView.swift */, + 2D34D9CA261489930081BFC0 /* SearchViewController+RecomendView.swift */, 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */, 2D34D9E026149C550081BFC0 /* CollectionViewCell */, ); @@ -2028,10 +2057,12 @@ 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */, 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */, + 5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */, DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */, DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */, 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */, + 2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */, 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */, @@ -2050,6 +2081,7 @@ DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */, DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */, DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */, + 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */, DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, @@ -2070,6 +2102,7 @@ DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, + 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, @@ -2079,6 +2112,7 @@ DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */, 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, + 2DE0FAC12615F04D00CDF649 /* RecomendHashTagSection.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */, DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */, @@ -2095,6 +2129,7 @@ 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, + 5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */, 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, 5D0393902612D259007FE196 /* WebViewController.swift in Sources */, @@ -2129,6 +2164,7 @@ DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */, DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */, + 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, @@ -2144,7 +2180,7 @@ DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, - 2D34D9CB261489930081BFC0 /* SearchViewController+recomendView.swift in Sources */, + 2D34D9CB261489930081BFC0 /* SearchViewController+RecomendView.swift in Sources */, DB482A45261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift in Sources */, 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/RecomendHashTagSection.swift b/Mastodon/Diffiable/Section/RecomendHashTagSection.swift new file mode 100644 index 000000000..2f78e73b9 --- /dev/null +++ b/Mastodon/Diffiable/Section/RecomendHashTagSection.swift @@ -0,0 +1,26 @@ +// +// RecomendHashTagSection.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/1. +// + +import Foundation +import MastodonSDK +import UIKit + +enum RecomendHashTagSection: Equatable, Hashable { + case main +} + +extension RecomendHashTagSection { + static func collectionViewDiffableDataSource( + for collectionView: UICollectionView + ) -> UICollectionViewDiffableDataSource { + UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, tag -> UICollectionViewCell? in + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self), for: indexPath) as! SearchRecommendTagsCollectionViewCell + cell.config(with: tag) + return cell + } + } +} diff --git a/Mastodon/Diffiable/Section/RecommendAccountSection.swift b/Mastodon/Diffiable/Section/RecommendAccountSection.swift new file mode 100644 index 000000000..b08c9abab --- /dev/null +++ b/Mastodon/Diffiable/Section/RecommendAccountSection.swift @@ -0,0 +1,26 @@ +// +// RecommendAccountSection.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/1. +// + +import Foundation +import MastodonSDK +import UIKit + +enum RecommendAccountSection: Equatable, Hashable { + case main +} + +extension RecommendAccountSection { + static func collectionViewDiffableDataSource( + for collectionView: UICollectionView + ) -> UICollectionViewDiffableDataSource { + UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, account -> UICollectionViewCell? in + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SearchRecommendAccountsCollectionViewCell.self), for: indexPath) as! SearchRecommendAccountsCollectionViewCell + cell.config(with: account) + return cell + } + } +} diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Account.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Account.swift new file mode 100644 index 000000000..8fd6bd67a --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Account.swift @@ -0,0 +1,18 @@ +// +// Mastodon+Entity+Account.swift +// Mastodon +// +// Created by xiaojian sun on 2021/4/2. +// + +import MastodonSDK + +extension Mastodon.Entity.Account: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public static func == (lhs: Mastodon.Entity.Account, rhs: Mastodon.Entity.Account) -> Bool { + return lhs.id == rhs.id + } +} diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+History.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+History.swift new file mode 100644 index 000000000..b116889b8 --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+History.swift @@ -0,0 +1,20 @@ +// +// Mastodon+Entity+History.swift +// Mastodon +// +// Created by xiaojian sun on 2021/4/2. +// + +import MastodonSDK + +extension Mastodon.Entity.History: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(uses) + hasher.combine(accounts) + hasher.combine(day) + } + + public static func == (lhs: Mastodon.Entity.History, rhs: Mastodon.Entity.History) -> Bool { + return lhs.uses == rhs.uses && lhs.uses == rhs.uses && lhs.day == rhs.day + } +} diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Tag.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Tag.swift new file mode 100644 index 000000000..caf819b38 --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Tag.swift @@ -0,0 +1,18 @@ +// +// Mastodon+Entity+Tag.swift +// Mastodon +// +// Created by xiaojian sun on 2021/4/2. +// + +import MastodonSDK + +extension Mastodon.Entity.Tag: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(name) + } + + public static func == (lhs: Mastodon.Entity.Tag, rhs: Mastodon.Entity.Tag) -> Bool { + return lhs.name == rhs.name + } +} diff --git a/Mastodon/Extension/UIView+Constraint.swift b/Mastodon/Extension/UIView+Constraint.swift index 42d3bfd93..baa923ada 100644 --- a/Mastodon/Extension/UIView+Constraint.swift +++ b/Mastodon/Extension/UIView+Constraint.swift @@ -42,17 +42,17 @@ extension UIView { attribute: .top, multiplier: 1.0, constant: toSuperviewEdges?.top ?? 0.0), - NSLayoutConstraint(item: self, + NSLayoutConstraint(item: view, attribute: .trailing, relatedBy: .equal, - toItem: view, + toItem: self, attribute: .trailing, multiplier: 1.0, constant: toSuperviewEdges?.right ?? 0.0), - NSLayoutConstraint(item: self, + NSLayoutConstraint(item: view, attribute: .bottom, relatedBy: .equal, - toItem: view, + toItem: self, attribute: .bottom, multiplier: 1.0, constant: toSuperviewEdges?.bottom ?? 0.0) @@ -89,40 +89,6 @@ extension UIView { constant: constant) } - func constraint(toBottom: UIView, constant: CGFloat) -> NSLayoutConstraint? { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil } - translatesAutoresizingMaskIntoConstraints = false - return NSLayoutConstraint(item: self, attribute: .top, relatedBy: .equal, toItem: toBottom, attribute: .bottom, multiplier: 1.0, constant: constant) - } - - func pinToBottom(to: UIView, height: CGFloat) { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.width, toView: to), - constraint(toBottom: to, constant: 0.0), - constraint(.height, constant: height) - ]) - } - - func constraint(toTop: UIView, constant: CGFloat) -> NSLayoutConstraint? { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil } - translatesAutoresizingMaskIntoConstraints = false - return NSLayoutConstraint(item: self, attribute: .bottom, relatedBy: .equal, toItem: toTop, attribute: .top, multiplier: 1.0, constant: constant) - } - - func constraint(toTrailing: UIView, constant: CGFloat) -> NSLayoutConstraint? { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil } - translatesAutoresizingMaskIntoConstraints = false - return NSLayoutConstraint(item: self, attribute: .leading, relatedBy: .equal, toItem: toTrailing, attribute: .trailing, multiplier: 1.0, constant: constant) - } - - func constraint(toLeading: UIView, constant: CGFloat) -> NSLayoutConstraint? { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil } - translatesAutoresizingMaskIntoConstraints = false - return NSLayoutConstraint(item: self, attribute: .trailing, relatedBy: .equal, toItem: toLeading, attribute: .leading, multiplier: 1.0, constant: constant) - } - func constrainTopCorners(sidePadding: CGFloat, topPadding: CGFloat, topLayoutGuide: UILayoutSupport) { guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } translatesAutoresizingMaskIntoConstraints = false @@ -204,17 +170,6 @@ extension UIView { ]) } - func pinTo(viewAbove: UIView, padding: CGFloat = 0.0, height: CGFloat? = nil) { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.width, toView: viewAbove), - constraint(toBottom: viewAbove, constant: padding), - self.centerXAnchor.constraint(equalTo: viewAbove.centerXAnchor), - height != nil ? constraint(.height, constant: height!) : nil - ]) - } - func pin(toSize: CGSize) { guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } translatesAutoresizingMaskIntoConstraints = false @@ -223,6 +178,25 @@ extension UIView { heightAnchor.constraint(equalToConstant: toSize.height)]) } + func pin(top: CGFloat?,left: CGFloat?,bottom: CGFloat?, right: CGFloat?) { + guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false + var constraints = [NSLayoutConstraint]() + if let topConstant = top { + constraints.append(topAnchor.constraint(equalTo: view.topAnchor, constant: topConstant)) + } + if let leftConstant = left { + constraints.append(leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: leftConstant)) + } + if let bottomConstant = bottom { + constraints.append(view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: bottomConstant)) + } + if let rightConstant = right { + constraints.append(view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: rightConstant)) + } + constrain(constraints) + + } func pinTopLeft(padding: CGFloat) { guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } translatesAutoresizingMaskIntoConstraints = false @@ -231,6 +205,14 @@ extension UIView { topAnchor.constraint(equalTo: view.topAnchor, constant: padding)]) } + func pinTopLeft(top: CGFloat, left: CGFloat) { + guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false + constrain([ + leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: left), + topAnchor.constraint(equalTo: view.topAnchor, constant: top)]) + } + func pinTopRight(padding: CGFloat) { guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } translatesAutoresizingMaskIntoConstraints = false @@ -238,6 +220,14 @@ extension UIView { view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: padding), topAnchor.constraint(equalTo: view.topAnchor, constant: padding)]) } + + func pinTopRight(top: CGFloat, right: CGFloat) { + guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false + constrain([ + view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: right), + topAnchor.constraint(equalTo: view.topAnchor, constant: top)]) + } func pinTopLeft(toView: UIView, topPadding: CGFloat) { guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 8276cfb20..241388199 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -43,6 +43,7 @@ internal enum Asset { internal static let danger = ColorAsset(name: "Colors/Background/danger") internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/media.type.indicotor") internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") + internal static let search = ColorAsset(name: "Colors/Background/search") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") internal static let success = ColorAsset(name: "Colors/Background/success") diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index a308033fc..0ce6bf212 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -406,6 +406,28 @@ internal enum L10n { } } internal enum Search { + internal enum Recommend { + /// See All + internal static let buttontext = L10n.tr("Localizable", "Scene.Search.Recommend.Buttontext") + internal enum Accounts { + /// Except for Sam, you will not like his account. + internal static let description = L10n.tr("Localizable", "Scene.Search.Recommend.Accounts.Description") + /// Follow + internal static let follow = L10n.tr("Localizable", "Scene.Search.Recommend.Accounts.Follow") + /// Accounts you might like + internal static let title = L10n.tr("Localizable", "Scene.Search.Recommend.Accounts.Title") + } + internal enum HashTag { + /// Hashtags that are getting quite a bit of attention among people you follow + internal static let description = L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.Description") + /// %@ people are talking + internal static func peopleTalking(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.PeopleTalking", String(describing: p1)) + } + /// Trending in your timeline + internal static let title = L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.Title") + } + } internal enum Searchbar { /// Cancel internal static let cancel = L10n.tr("Localizable", "Scene.Search.Searchbar.Cancel") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/search.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/search.colorset/Contents.json new file mode 100644 index 000000000..838e44e44 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/search.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "232", + "green" : "225", + "red" : "217" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 88ecd2508..f0ac3d44b 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -129,6 +129,13 @@ tap the link to confirm your account."; "Scene.Register.Input.Username.DuplicatePrompt" = "This username is taken."; "Scene.Register.Input.Username.Placeholder" = "username"; "Scene.Register.Title" = "Tell us about you."; +"Scene.Search.Recommend.Accounts.Description" = "Except for Sam, you will not like his account."; +"Scene.Search.Recommend.Accounts.Follow" = "Follow"; +"Scene.Search.Recommend.Accounts.Title" = "Accounts you might like"; +"Scene.Search.Recommend.Buttontext" = "See All"; +"Scene.Search.Recommend.HashTag.Description" = "Hashtags that are getting quite a bit of attention among people you follow"; +"Scene.Search.Recommend.HashTag.PeopleTalking" = "%@ people are talking"; +"Scene.Search.Recommend.HashTag.Title" = "Trending in your timeline"; "Scene.Search.Searchbar.Cancel" = "Cancel"; "Scene.Search.Searchbar.Placeholder" = "Search hashtags and users"; "Scene.ServerPicker.Button.Category.All" = "All"; diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index 535c23db9..6439ea42b 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -58,7 +58,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let largeTitleLabel: UILabel = { let label = UILabel() label.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: UIFont.boldSystemFont(ofSize: 34)) - label.textColor = .black + label.textColor = Asset.Colors.Label.primary.color label.text = L10n.Scene.Register.title return label }() @@ -97,7 +97,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let domainLabel: UILabel = { let label = UILabel() label.font = .preferredFont(forTextStyle: .headline) - label.textColor = .black + label.textColor = Asset.Colors.Label.primary.color return label }() diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index 45b4599a9..cd6106c23 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -185,9 +185,9 @@ extension MastodonRegisterViewModel { let attributeString = NSMutableAttributedString() let image = MastodonRegisterViewModel.checkmarkImage(font: font) - attributeString.append(attributedStringImage(with: image, tintColor: validateState == .valid ? .black : .clear)) + attributeString.append(attributedStringImage(with: image, tintColor: validateState == .valid ? Asset.Colors.Label.primary.color : .clear)) attributeString.append(NSAttributedString(string: " ")) - let eightCharactersDescription = NSAttributedString(string: L10n.Scene.Register.Input.Password.hint, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: UIColor.black]) + let eightCharactersDescription = NSAttributedString(string: L10n.Scene.Register.Input.Password.hint, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: Asset.Colors.Label.primary.color]) attributeString.append(eightCharactersDescription) return attributeString diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift index 5fb526218..b51d66b2b 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift @@ -40,7 +40,7 @@ final class MastodonServerRulesViewController: UIViewController, NeedsDependency let rulesLabel: UILabel = { let label = UILabel() label.font = .preferredFont(forTextStyle: .body) - label.textColor = .black + label.textColor = Asset.Colors.Label.primary.color label.text = "Rules" label.numberOfLines = 0 return label diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift index 14b4f0941..b1d000db5 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift @@ -40,7 +40,7 @@ final class MastodonServerRulesViewModel { let imageName = String(i + 1) + ".circle.fill" let image = UIImage(systemName: imageName, withConfiguration: configuration)! let attachment = NSTextAttachment() - attachment.image = image.withTintColor(.black) + attachment.image = image.withTintColor(Asset.Colors.Label.primary.color) let imageAttribute = NSAttributedString(attachment: attachment) let ruleString = NSAttributedString(string: " " + rule.text + "\n\n") diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift new file mode 100644 index 000000000..ba4babc52 --- /dev/null +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift @@ -0,0 +1,150 @@ +// +// SearchRecommendAccountsCollectionViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/1. +// + +import Foundation +import MastodonSDK +import UIKit + +class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { + let avatarImageView: UIImageView = { + let imageView = UIImageView() + imageView.layer.cornerRadius = 8 + imageView.clipsToBounds = true + return imageView + }() + + let headerImageView: UIImageView = { + let imageView = UIImageView() + imageView.layer.cornerRadius = 8 + imageView.clipsToBounds = true + return imageView + }() + + let displayNameLabel: UILabel = { + let label = UILabel() + label.textColor = .white + label.font = .systemFont(ofSize: 18, weight: .semibold) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + let acctLabel: UILabel = { + let label = UILabel() + label.textColor = .white + label.font = .preferredFont(forTextStyle: .body) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + let followButton: UIButton = { + let button = UIButton(type: .custom) + button.setTitleColor(.white, for: .normal) + button.setTitle(L10n.Scene.Search.Recommend.Accounts.follow, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold) + button.layer.cornerRadius = 12 + button.layer.borderWidth = 3 + button.layer.borderColor = UIColor.white.cgColor + return button + }() + + override func prepareForReuse() { + super.prepareForReuse() + headerImageView.af.cancelImageRequest() + avatarImageView.af.cancelImageRequest() + } + + override init(frame: CGRect) { + super.init(frame: .zero) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +extension SearchRecommendAccountsCollectionViewCell { + private func configure() { + headerImageView.backgroundColor = Asset.Colors.buttonDefault.color + layer.cornerRadius = 8 + clipsToBounds = true + + contentView.addSubview(headerImageView) + headerImageView.pin(top: 16, left: 0, bottom: 0, right: 0) + + contentView.addSubview(avatarImageView) + avatarImageView.pin(toSize: CGSize(width: 88, height: 88)) + avatarImageView.constrain([ + avatarImageView.constraint(.top, toView: contentView), + avatarImageView.constraint(.centerX, toView: contentView) + ]) + + contentView.addSubview(displayNameLabel) + displayNameLabel.constrain([ + displayNameLabel.constraint(.top, toView: contentView, constant: 108), + displayNameLabel.constraint(.centerX, toView: contentView) + ]) + + contentView.addSubview(acctLabel) + acctLabel.constrain([ + acctLabel.constraint(.top, toView: contentView, constant: 132), + acctLabel.constraint(.centerX, toView: contentView) + ]) + + contentView.addSubview(followButton) + followButton.pin(toSize: CGSize(width: 76, height: 24)) + followButton.constrain([ + followButton.constraint(.top, toView: contentView, constant: 159), + followButton.constraint(.centerX, toView: contentView) + ]) + } + + func config(with account: Mastodon.Entity.Account) { + displayNameLabel.text = account.displayName.isEmpty ? account.username : account.displayName + acctLabel.text = account.acct + avatarImageView.af.setImage( + withURL: URL(string: account.avatar)!, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + headerImageView.af.setImage( + withURL: URL(string: account.header)!, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SearchRecommendAccountsCollectionViewCell_Previews: PreviewProvider { + static var controls: some View { + Group { + UIViewPreview { + let cell = SearchRecommendAccountsCollectionViewCell() + cell.avatarImageView.backgroundColor = .white + cell.headerImageView.backgroundColor = .red + cell.displayNameLabel.text = "sunxiaojian" + cell.acctLabel.text = "sunxiaojian@mastodon.online" + return cell + } + .previewLayout(.fixed(width: 257, height: 202)) + } + } + + static var previews: some View { + Group { + controls.colorScheme(.light) + controls.colorScheme(.dark) + } + .background(Color.gray) + } +} + +#endif diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift index 108f2b6a2..685d214e6 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift @@ -6,6 +6,7 @@ // import Foundation +import MastodonSDK import UIKit class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { @@ -18,8 +19,9 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { let hashTagTitleLabel: UILabel = { let label = UILabel() label.textColor = .white - label.font = .preferredFont(forTextStyle: .caption1) + label.font = .systemFont(ofSize: 20, weight: .semibold) label.translatesAutoresizingMaskIntoConstraints = false + label.lineBreakMode = .byTruncatingTail return label }() @@ -57,20 +59,67 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { extension SearchRecommendTagsCollectionViewCell { private func configure() { + backgroundColor = Asset.Colors.buttonDefault.color + layer.cornerRadius = 8 + clipsToBounds = true + contentView.addSubview(backgroundImageView) backgroundImageView.constrain(toSuperviewEdges: nil) contentView.addSubview(hashTagTitleLabel) - hashTagTitleLabel.pinTopLeft(padding: 16) + hashTagTitleLabel.pin(top: 16, left: 16, bottom: nil, right: 42) contentView.addSubview(peopleLabel) - peopleLabel.constrain([ - peopleLabel.constraint(toTop: contentView, constant: 46), - peopleLabel.constraint(toLeading: contentView, constant: 16) - ]) + peopleLabel.pinTopLeft(top: 46, left: 16) contentView.addSubview(flameIconView) flameIconView.pinTopRight(padding: 16) - + } + + func config(with tag: Mastodon.Entity.Tag) { + hashTagTitleLabel.text = "# " + tag.name + guard let historys = tag.history else { + peopleLabel.text = "" + return + } + var recentHistory = [Mastodon.Entity.History]() + for history in historys { + if Int(history.uses) == 0 { + break + } else { + recentHistory.append(history) + } + } + let peopleAreTalking = recentHistory.compactMap({ Int($0.accounts) }).reduce(0, +) + let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) + peopleLabel.text = string + } } + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SearchRecommendTagsCollectionViewCell_Previews: PreviewProvider { + static var controls: some View { + Group { + UIViewPreview { + let cell = SearchRecommendTagsCollectionViewCell() + cell.hashTagTitleLabel.text = "# test" + cell.peopleLabel.text = "128 people are talking" + return cell + } + .previewLayout(.fixed(width: 228, height: 130)) + } + } + + static var previews: some View { + Group { + controls.colorScheme(.light) + controls.colorScheme(.dark) + } + .background(Color.gray) + } +} + +#endif diff --git a/Mastodon/Scene/Search/SearchViewController+RecomendView.swift b/Mastodon/Scene/Search/SearchViewController+RecomendView.swift new file mode 100644 index 000000000..ca373b6b5 --- /dev/null +++ b/Mastodon/Scene/Search/SearchViewController+RecomendView.swift @@ -0,0 +1,120 @@ +// +// SearchViewController+RecomendView.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/31. +// + +import Foundation +import MastodonSDK +import OSLog +import UIKit + +extension SearchViewController { + func setupHashTagCollectionView() { + let header = SearchRecommendCollectionHeader() + header.titleLabel.text = L10n.Scene.Search.Recommend.HashTag.title + header.descriptionLabel.text = L10n.Scene.Search.Recommend.HashTag.description + header.seeAllButton.addTarget(self, action: #selector(SearchViewController.hashTagSeeAllButtonPressed(_:)), for: .touchUpInside) + stackView.addArrangedSubview(header) + + hashTagCollectionView.register(SearchRecommendTagsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self)) + hashTagCollectionView.delegate = self + + stackView.addArrangedSubview(hashTagCollectionView) + hashTagCollectionView.constrain([ + hashTagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 130) + ]) + + viewModel.requestRecommendHashTags() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + if !self.viewModel.recommendHashTags.isEmpty { + let dataSource = RecomendHashTagSection.collectionViewDiffableDataSource(for: self.hashTagCollectionView) + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(self.viewModel.recommendHashTags, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + self.hashTagDiffableDataSource = dataSource + } + } receiveValue: { _ in + } + .store(in: &disposeBag) + } + + func setupAccountsCollectionView() { + let header = SearchRecommendCollectionHeader() + header.titleLabel.text = L10n.Scene.Search.Recommend.Accounts.title + header.descriptionLabel.text = L10n.Scene.Search.Recommend.Accounts.description + header.seeAllButton.addTarget(self, action: #selector(SearchViewController.accountSeeAllButtonPressed(_:)), for: .touchUpInside) + stackView.addArrangedSubview(header) + + accountsCollectionView.register(SearchRecommendAccountsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendAccountsCollectionViewCell.self)) + accountsCollectionView.delegate = self + + stackView.addArrangedSubview(accountsCollectionView) + accountsCollectionView.constrain([ + accountsCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 202) + ]) + + viewModel.requestRecommendAccounts() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + if !self.viewModel.recommendAccounts.isEmpty { + let dataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: self.accountsCollectionView) + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(self.viewModel.recommendAccounts, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + self.accountDiffableDataSource = dataSource + } + } receiveValue: { _ in + } + .store(in: &disposeBag) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + hashTagCollectionView.collectionViewLayout.invalidateLayout() + accountsCollectionView.collectionViewLayout.invalidateLayout() + } +} + +extension SearchViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s", (#file as NSString).lastPathComponent, #line, #function, indexPath.debugDescription) + collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally) + } +} + +// MARK: - UICollectionViewDelegateFlowLayout + +extension SearchViewController: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { + UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { + if collectionView == hashTagCollectionView { + return 6 + } else { + return 12 + } + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + if collectionView == hashTagCollectionView { + return CGSize(width: 228, height: 130) + } else { + return CGSize(width: 257, height: 202) + } + } +} + +extension SearchViewController { + @objc func hashTagSeeAllButtonPressed(_ sender: UIButton) {} + + @objc func accountSeeAllButtonPressed(_ sender: UIButton) {} +} diff --git a/Mastodon/Scene/Search/SearchViewController+recomendView.swift b/Mastodon/Scene/Search/SearchViewController+recomendView.swift deleted file mode 100644 index b498aa608..000000000 --- a/Mastodon/Scene/Search/SearchViewController+recomendView.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// SearchViewController+recommendView.swift -// Mastodon -// -// Created by sxiaojian on 2021/3/31. -// - -import Foundation -import UIKit - - -extension SearchViewController { - func setuprecommendView() { - recommendView.register(SearchRecommendTagsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self)) - recommendView.dataSource = self - recommendView.delegate = self - } - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - recommendView.collectionViewLayout.invalidateLayout() - } -} - -extension SearchViewController: UICollectionViewDelegate { - -} - -extension SearchViewController: UICollectionViewDataSource { - func numberOfSections(in collectionView: UICollectionView) -> Int { - return (self.viewModel.recommendAccounts.isEmpty ? 0 : 1) + (self.viewModel.recommendHashTags.isEmpty ? 0 : 1) - } - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - switch section { - case 0: - return viewModel.recommendHashTags.count - case 1: - return viewModel.recommendAccounts.count - default: - return 0 - } - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - return UICollectionViewCell() - } - - -} diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index f76f596c0..856407f33 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -5,11 +5,11 @@ // Created by sxiaojian on 2021/3/31. // -import UIKit import Combine +import MastodonSDK +import UIKit final class SearchViewController: UIViewController, NeedsDependency { - weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -27,7 +27,40 @@ final class SearchViewController: UIViewController, NeedsDependency { return searchBar }() - let recommendView: UICollectionView = { + let scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.showsVerticalScrollIndicator = false + scrollView.alwaysBounceVertical = true + scrollView.clipsToBounds = false + return scrollView + }() + + let stackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.distribution = .fill + stackView.spacing = 0 + stackView.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 68, right: 0) + stackView.isLayoutMarginsRelativeArrangement = true + return stackView + }() + + let hashTagCollectionView: UICollectionView = { + let flowLayout = UICollectionViewFlowLayout() + flowLayout.scrollDirection = .horizontal + let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout) + view.backgroundColor = .clear + view.showsHorizontalScrollIndicator = false + view.showsVerticalScrollIndicator = false + view.layer.masksToBounds = false + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + var hashTagDiffableDataSource: UICollectionViewDiffableDataSource? + var accountDiffableDataSource: UICollectionViewDiffableDataSource? + + let accountsCollectionView: UICollectionView = { let flowLayout = UICollectionViewFlowLayout() flowLayout.scrollDirection = .horizontal let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout) @@ -41,15 +74,36 @@ final class SearchViewController: UIViewController, NeedsDependency { } extension SearchViewController { - override func viewDidLoad() { super.viewDidLoad() + view.backgroundColor = Asset.Colors.Background.search.color searchBar.delegate = self navigationItem.titleView = searchBar navigationItem.hidesBackButton = true - viewModel.requestRecommendData() + setupScrollView() + setupHashTagCollectionView() + setupAccountsCollectionView() + } + + func setupScrollView() { + view.addSubview(scrollView) + scrollView.constrain([ + scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + scrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), + ]) + + scrollView.addSubview(stackView) + stackView.constrain([ + stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + stackView.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor), + scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: stackView.bottomAnchor), + ]) } - } extension SearchViewController: UISearchBarDelegate { @@ -71,11 +125,20 @@ extension SearchViewController: UISearchBarDelegate { viewModel.searchText.send(searchText) } - func searchBarBookmarkButtonClicked(_ searchBar: UISearchBar) { - + func searchBarBookmarkButtonClicked(_ searchBar: UISearchBar) {} +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SearchViewController_Previews: PreviewProvider { + static var previews: some View { + UIViewControllerPreview { + let viewController = SearchViewController() + return viewController + } + .previewLayout(.fixed(width: 375, height: 800)) } } -extension SearchViewController { - -} +#endif diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 679a2cfaf..40b22c880 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -5,14 +5,13 @@ // Created by sxiaojian on 2021/3/31. // -import Foundation import Combine +import Foundation import MastodonSDK -import UIKit import OSLog +import UIKit final class SearchViewModel { - var disposeBag = Set() // input @@ -25,30 +24,54 @@ final class SearchViewModel { var recommendAccounts = [Mastodon.Entity.Account]() init(context: AppContext) { - self.context = context + self.context = context } - func requestRecommendData() { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - let trendsAPI = context.apiService.recommendTrends(domain: activeMastodonAuthenticationBox.domain, query: Mastodon.API.Trends.Query(limit: 5)) - - let accountsAPI = context.apiService.recommendAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) - Publishers.Zip(trendsAPI,accountsAPI) - .sink { completion in - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: zip request fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: zip request success", ((#file as NSString).lastPathComponent), #line, #function) - break - } - } receiveValue: { [weak self] (tags, accounts) in - guard let self = self else { return } - self.recommendAccounts = accounts.value - self.recommendHashTags = tags.value + func requestRecommendHashTags() -> Future { + Future { promise in + guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { + promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing))) + return } - .store(in: &disposeBag) + self.context.apiService.recommendTrends(domain: activeMastodonAuthenticationBox.domain, query: nil) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags 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: recommendHashTags request success", (#file as NSString).lastPathComponent, #line, #function) + promise(.success(())) + } + } receiveValue: { [weak self] tags in + guard let self = self else { return } + self.recommendHashTags = tags.value + } + .store(in: &self.disposeBag) + } + } + + func requestRecommendAccounts() -> Future { + 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.recommendAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags 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: recommendHashTags request success", (#file as NSString).lastPathComponent, #line, #function) + promise(.success(())) + } + } receiveValue: { [weak self] accounts in + guard let self = self else { return } + self.recommendAccounts = accounts.value + } + .store(in: &self.disposeBag) + } } } diff --git a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift new file mode 100644 index 000000000..00efecd85 --- /dev/null +++ b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift @@ -0,0 +1,87 @@ +// +// SearchRecommendCollectionHeader.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/1. +// + +import Foundation +import UIKit + +class SearchRecommendCollectionHeader: UIView { + let titleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.primary.color + label.font = .systemFont(ofSize: 20, weight: .semibold) + return label + }() + + let descriptionLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.lightSecondaryText.color + label.font = .preferredFont(forTextStyle: .body) + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + return label + }() + + let seeAllButton: UIButton = { + let button = UIButton(type: .custom) + button.setTitleColor(Asset.Colors.buttonDefault.color, for: .normal) + button.setTitle(L10n.Scene.Search.Recommend.buttontext, for: .normal) + return button + }() + + override init(frame: CGRect) { + super.init(frame: .zero) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +extension SearchRecommendCollectionHeader { + private func configure() { + backgroundColor = .clear + translatesAutoresizingMaskIntoConstraints = false + addSubview(titleLabel) + titleLabel.pinTopLeft(top: 31, left: 16) + + addSubview(descriptionLabel) + descriptionLabel.constrain(toSuperviewEdges: UIEdgeInsets(top: 60, left: 16, bottom: 16, right: 16)) + + addSubview(seeAllButton) + seeAllButton.pinTopRight(top: 26, right: 16) + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SearchRecommendCollectionHeader_Previews: PreviewProvider { + static var controls: some View { + Group { + UIViewPreview { + let cell = SearchRecommendCollectionHeader() + cell.titleLabel.text = "Trending in your timeline" + cell.descriptionLabel.text = "Hashtags that are getting quite a bit of attention among people you follow" + cell.seeAllButton.setTitle("See All", for: .normal) + return cell + } + .previewLayout(.fixed(width: 320, height: 116)) + } + } + + static var previews: some View { + Group { + controls.colorScheme(.light) + controls.colorScheme(.dark) + } + .background(Color.gray) + } +} + +#endif diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift index 82fc9502b..13f3c0a71 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift @@ -82,5 +82,6 @@ extension Mastodon.Entity { case muteExpiresAt = "mute_expires_at" } } + } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift index 61c47ed68..e7f095eb3 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift @@ -22,5 +22,10 @@ extension Mastodon.Entity { public let url: String public let history: [History]? + enum CodingKeys: String, CodingKey { + case name + case url + case history + } } }