diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
index 9e5b7cf3..5ed4021a 100644
--- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
+++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
@@ -1,5 +1,5 @@
-
+
@@ -141,12 +141,19 @@
-
+
+
+
+
+
+
+
+
@@ -189,23 +196,25 @@
+
-
-
-
-
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
-
+
-
\ No newline at end of file
+
diff --git a/CoreDataStack/Entity/History.swift b/CoreDataStack/Entity/History.swift
index 11487929..6fe703e8 100644
--- a/CoreDataStack/Entity/History.swift
+++ b/CoreDataStack/Entity/History.swift
@@ -40,6 +40,26 @@ public extension History {
}
}
+public extension History {
+ func update(day: Date) {
+ if self.day != day {
+ self.day = day
+ }
+ }
+
+ func update(uses: String) {
+ if self.uses != uses {
+ self.uses = uses
+ }
+ }
+
+ func update(accounts: String) {
+ if self.accounts != accounts {
+ self.accounts = accounts
+ }
+ }
+}
+
public extension History {
struct Property {
public let day: Date
diff --git a/CoreDataStack/Entity/SearchHistory.swift b/CoreDataStack/Entity/SearchHistory.swift
new file mode 100644
index 00000000..d924917e
--- /dev/null
+++ b/CoreDataStack/Entity/SearchHistory.swift
@@ -0,0 +1,66 @@
+//
+// SearchHistory.swift
+// CoreDataStack
+//
+// Created by sxiaojian on 2021/4/7.
+//
+
+import Foundation
+import CoreData
+
+public final class SearchHistory: NSManagedObject {
+ public typealias ID = UUID
+ @NSManaged public private(set) var identifier: ID
+ @NSManaged public private(set) var createAt: Date
+ @NSManaged public private(set) var updatedAt: Date
+
+ @NSManaged public private(set) var account: MastodonUser?
+ @NSManaged public private(set) var hashtag: Tag?
+
+}
+
+extension SearchHistory {
+ public override func awakeFromInsert() {
+ super.awakeFromInsert()
+ setPrimitiveValue(UUID(), forKey: #keyPath(SearchHistory.identifier))
+ setPrimitiveValue(Date(), forKey: #keyPath(SearchHistory.createAt))
+ setPrimitiveValue(Date(), forKey: #keyPath(SearchHistory.updatedAt))
+ }
+
+ public override func willSave() {
+ super.willSave()
+ setPrimitiveValue(Date(), forKey: #keyPath(SearchHistory.updatedAt))
+ }
+
+ @discardableResult
+ public static func insert(
+ into context: NSManagedObjectContext,
+ account: MastodonUser
+ ) -> SearchHistory {
+ let searchHistory: SearchHistory = context.insertObject()
+ searchHistory.account = account
+ return searchHistory
+ }
+
+ @discardableResult
+ public static func insert(
+ into context: NSManagedObjectContext,
+ hashtag: Tag
+ ) -> SearchHistory {
+ let searchHistory: SearchHistory = context.insertObject()
+ searchHistory.hashtag = hashtag
+ return searchHistory
+ }
+}
+
+public extension SearchHistory {
+ func update(updatedAt: Date) {
+ setValue(updatedAt, forKey: #keyPath(SearchHistory.updatedAt))
+ }
+}
+
+extension SearchHistory: Managed {
+ public static var defaultSortDescriptors: [NSSortDescriptor] {
+ return [NSSortDescriptor(keyPath: \SearchHistory.updatedAt, ascending: false)]
+ }
+}
diff --git a/CoreDataStack/Entity/Tag.swift b/CoreDataStack/Entity/Tag.swift
index 2f1914c4..3044cacc 100644
--- a/CoreDataStack/Entity/Tag.swift
+++ b/CoreDataStack/Entity/Tag.swift
@@ -12,25 +12,33 @@ public final class Tag: NSManagedObject {
public typealias ID = UUID
@NSManaged public private(set) var identifier: ID
@NSManaged public private(set) var createAt: Date
-
+ @NSManaged public private(set) var updatedAt: Date
+
@NSManaged public private(set) var name: String
@NSManaged public private(set) var url: String
-
+
// many-to-many relationship
@NSManaged public private(set) var statuses: Set?
-
+
// one-to-many relationship
@NSManaged public private(set) var histories: Set?
}
-extension Tag {
- public override func awakeFromInsert() {
+public extension Tag {
+ override func awakeFromInsert() {
super.awakeFromInsert()
setPrimitiveValue(UUID(), forKey: #keyPath(Tag.identifier))
+ setPrimitiveValue(Date(), forKey: #keyPath(Tag.createAt))
+ setPrimitiveValue(Date(), forKey: #keyPath(Tag.updatedAt))
}
-
+
+ override func willSave() {
+ super.willSave()
+ setPrimitiveValue(Date(), forKey: #keyPath(Tag.updatedAt))
+ }
+
@discardableResult
- public static func insert(
+ static func insert(
into context: NSManagedObjectContext,
property: Property
) -> Tag {
@@ -44,8 +52,8 @@ extension Tag {
}
}
-extension Tag {
- public struct Property {
+public extension Tag {
+ struct Property {
public let name: String
public let url: String
public let histories: [History]?
@@ -58,8 +66,36 @@ extension Tag {
}
}
-extension Tag: Managed {
- public static var defaultSortDescriptors: [NSSortDescriptor] {
- return [NSSortDescriptor(keyPath: \Tag.createAt, ascending: false)]
+public extension Tag {
+ func updateHistory(index: Int, day: Date, uses: String, account: String) {
+ guard let histories = self.histories?.sorted(by: {
+ $0.createAt.compare($1.createAt) == .orderedAscending
+ }) else { return }
+ let history = histories[index]
+ history.update(day: day)
+ history.update(uses: uses)
+ history.update(accounts: account)
+ }
+
+ func appendHistory(history: History) {
+ self.mutableSetValue(forKeyPath: #keyPath(Tag.histories)).add(history)
+ }
+
+ func update(url: String) {
+ if self.url != url {
+ self.url = url
+ }
+ }
+}
+
+extension Tag: Managed {
+ public static var defaultSortDescriptors: [NSSortDescriptor] {
+ [NSSortDescriptor(keyPath: \Tag.createAt, ascending: false)]
+ }
+}
+
+public extension Tag {
+ static func predicate(name: String) -> NSPredicate {
+ NSPredicate(format: "%K == %@", #keyPath(Tag.name), name)
}
}
diff --git a/Localization/app.json b/Localization/app.json
index e0359caa..08a70feb 100644
--- a/Localization/app.json
+++ b/Localization/app.json
@@ -295,7 +295,7 @@
"cancel": "Cancel"
},
"recommend": {
- "buttonText": "See All",
+ "button_text": "See All",
"hash_tag": {
"title": "Trending in your timeline",
"description": "Hashtags that are getting quite a bit of attention among people you follow",
@@ -306,6 +306,15 @@
"description": "Except for Sam, you will not like his account.",
"follow": "Follow"
}
+ },
+ "searching": {
+ "segment": {
+ "all": "All",
+ "people": "People",
+ "hashtags": "Hashtags"
+ },
+ "recent_search": "Recent searches",
+ "clear": "clear"
}
},
"hashtag": {
@@ -327,4 +336,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj
index 6076f2ee..ea937b52 100644
--- a/Mastodon.xcodeproj/project.pbxproj
+++ b/Mastodon.xcodeproj/project.pbxproj
@@ -30,8 +30,13 @@
0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D33725E6401400AAD544 /* PickServerCell.swift */; };
18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; };
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; };
+ 2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0B7A1C261D839600B44727 /* SearchHistory.swift */; };
2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; };
2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; };
+ 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; };
+ 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198648261C0B8500F0B013 /* SearchResultSection.swift */; };
+ 2D19864F261C372A00F0B013 /* SearchBottomLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D19864E261C372A00F0B013 /* SearchBottomLoader.swift */; };
+ 2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */; };
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; };
2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7F25F5F45E00143C56 /* UIImage.swift */; };
2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; };
@@ -40,7 +45,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+Recommend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9CA261489930081BFC0 /* SearchViewController+Recommend.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 */; };
@@ -86,6 +91,7 @@
2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76319E25C1521200929FB9 /* StatusSection.swift */; };
2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */; };
2D7631B325C159F700929FB9 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631B225C159F700929FB9 /* Item.swift */; };
+ 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */; };
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */; };
2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */; };
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */; };
@@ -105,7 +111,7 @@
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 */; };
+ 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.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 */; };
@@ -114,6 +120,8 @@
2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */; };
2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */; };
2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; };
+ 2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */; };
+ 2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */; };
2DFF41892614A4DC00F776A4 /* UIView+Constraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */; };
5D0393902612D259007FE196 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D03938F2612D259007FE196 /* WebViewController.swift */; };
5D0393962612D266007FE196 /* WebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0393952612D266007FE196 /* WebViewModel.swift */; };
@@ -127,6 +135,7 @@
5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */; };
5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */; };
5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */; };
+ 5DFC35DF262068D20045711D /* SearchViewController+Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */; };
5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; };
5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; };
87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; };
@@ -412,8 +421,13 @@
0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSearchCell.swift; sourceTree = ""; };
0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; sourceTree = ""; };
2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = ""; };
+ 2D0B7A1C261D839600B44727 /* SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistory.swift; sourceTree = ""; };
2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = ""; };
2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; };
+ 2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; };
+ 2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = ""; };
+ 2D19864E261C372A00F0B013 /* SearchBottomLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBottomLoader.swift; sourceTree = ""; };
+ 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewModel+LoadOldestState.swift"; sourceTree = ""; };
2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = ""; };
2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; };
2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; };
@@ -422,7 +436,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+Recommend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Recommend.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 = ""; };
@@ -465,6 +479,7 @@
2D76319E25C1521200929FB9 /* StatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusSection.swift; sourceTree = ""; };
2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = ""; };
2D7631B225C159F700929FB9 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = ""; };
+ 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Tag.swift"; sourceTree = ""; };
2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewControllerAppearance.swift; sourceTree = ""; };
2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModelNavigationDelegateShim.swift; sourceTree = ""; };
2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarTitleViewModel.swift; sourceTree = ""; };
@@ -484,7 +499,7 @@
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 = ""; };
+ 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendHashTagSection.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 = ""; };
@@ -493,6 +508,8 @@
2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Favorite.swift"; sourceTree = ""; };
2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectObserver.swift; sourceTree = ""; };
2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectContextObjectsDidChange.swift; sourceTree = ""; };
+ 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Searching.swift"; sourceTree = ""; };
+ 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchingTableViewCell.swift; sourceTree = ""; };
2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Constraint.swift"; sourceTree = ""; };
2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.debug.xcconfig"; sourceTree = ""; };
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -509,6 +526,7 @@
5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerContainerView.swift; sourceTree = ""; };
5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchBlockingView.swift; sourceTree = ""; };
5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NeedsDependency+AVPlayerViewControllerDelegate.swift"; sourceTree = ""; };
+ 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Follow.swift"; sourceTree = ""; };
75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release.xcconfig"; sourceTree = ""; };
9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release.xcconfig"; sourceTree = ""; };
A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -1037,8 +1055,9 @@
DB4481C525EE2ADA00BEFB67 /* PollSection.swift */,
DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */,
DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */,
- 2DE0FAC02615F04D00CDF649 /* RecomendHashTagSection.swift */,
+ 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */,
2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */,
+ 2D198648261C0B8500F0B013 /* SearchResultSection.swift */,
DB66729525F9F91600D60309 /* ComposeStatusSection.swift */,
DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */,
);
@@ -1090,6 +1109,7 @@
isa = PBXGroup;
children = (
2D7631B225C159F700929FB9 /* Item.swift */,
+ 2D198642261BF09500F0B013 /* SearchResultItem.swift */,
DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */,
DB1E347725F519300079D7DF /* PickServerItem.swift */,
DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */,
@@ -1124,6 +1144,15 @@
path = Stack;
sourceTree = "";
};
+ 2DFAD5212616F8E300F9EE7C /* TableViewCell */ = {
+ isa = PBXGroup;
+ children = (
+ 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */,
+ 2D19864E261C372A00F0B013 /* SearchBottomLoader.swift */,
+ );
+ path = TableViewCell;
+ sourceTree = "";
+ };
3FE14AD363ED19AE7FF210A6 /* Frameworks */ = {
isa = PBXGroup;
children = (
@@ -1329,6 +1358,7 @@
2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Status.swift */,
DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */,
DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */,
+ 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */,
);
path = CoreData;
sourceTree = "";
@@ -1485,6 +1515,7 @@
isa = PBXGroup;
children = (
DB89BA2625C110B4008580ED /* Status.swift */,
+ 2D0B7A1C261D839600B44727 /* SearchHistory.swift */,
DB8AF52425C131D1002E6C99 /* MastodonUser.swift */,
DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */,
2D927F0125C7E4F2004F19B8 /* Mention.swift */,
@@ -1637,10 +1668,14 @@
DB9D6BEE25E4F5370051B173 /* Search */ = {
isa = PBXGroup;
children = (
+ 2DFAD5212616F8E300F9EE7C /* TableViewCell */,
2DE0FAC62615F5D200CDF649 /* View */,
DB9D6BE825E4F5340051B173 /* SearchViewController.swift */,
- 2D34D9CA261489930081BFC0 /* SearchViewController+RecomendView.swift */,
+ 2D34D9CA261489930081BFC0 /* SearchViewController+Recommend.swift */,
+ 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */,
+ 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */,
2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */,
+ 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */,
2D34D9E026149C550081BFC0 /* CollectionViewCell */,
);
path = Search;
@@ -2192,6 +2227,7 @@
DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */,
DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */,
2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */,
+ 2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */,
DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */,
DBB525502611ED6D002F1F29 /* ProfileHeaderView.swift in Sources */,
0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */,
@@ -2225,6 +2261,7 @@
DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */,
2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */,
2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */,
+ 2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */,
2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */,
DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */,
DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */,
@@ -2239,6 +2276,7 @@
2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */,
DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */,
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
+ 2D19864F261C372A00F0B013 /* SearchBottomLoader.swift in Sources */,
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */,
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */,
@@ -2249,6 +2287,7 @@
2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */,
DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */,
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */,
+ 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */,
DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */,
DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */,
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
@@ -2281,13 +2320,14 @@
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */,
DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */,
DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */,
+ 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */,
DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */,
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */,
2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */,
DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */,
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */,
5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */,
- 2DE0FAC12615F04D00CDF649 /* RecomendHashTagSection.swift in Sources */,
+ 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */,
2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */,
DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */,
DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */,
@@ -2360,7 +2400,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+Recommend.swift in Sources */,
DB938F1526241FDF00E5B6C1 /* APIService+Thread.swift in Sources */,
DB482A45261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift in Sources */,
2D206B8625F5FB0900143C56 /* Double.swift in Sources */,
@@ -2406,9 +2446,11 @@
2D42FF6B25C817D2004A627A /* MastodonStatusContent.swift in Sources */,
2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */,
DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */,
+ 5DFC35DF262068D20045711D /* SearchViewController+Follow.swift in Sources */,
DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */,
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */,
2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */,
+ 2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */,
0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */,
2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */,
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */,
@@ -2425,6 +2467,7 @@
DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */,
DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */,
DB9A489026035963008B817C /* APIService+Media.swift in Sources */,
+ 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */,
DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */,
2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */,
DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */,
@@ -2491,6 +2534,7 @@
2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */,
2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */,
DB89BA1D25C1107F008580ED /* URL.swift in Sources */,
+ 2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */,
2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
diff --git a/Mastodon/Diffiable/Item/SearchResultItem.swift b/Mastodon/Diffiable/Item/SearchResultItem.swift
new file mode 100644
index 00000000..53a36e2e
--- /dev/null
+++ b/Mastodon/Diffiable/Item/SearchResultItem.swift
@@ -0,0 +1,58 @@
+//
+// SearchResultItem.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/6.
+//
+
+import CoreData
+import Foundation
+import MastodonSDK
+
+enum SearchResultItem {
+ case hashtag(tag: Mastodon.Entity.Tag)
+
+ case account(account: Mastodon.Entity.Account)
+
+ case accountObjectID(accountObjectID: NSManagedObjectID)
+
+ case hashtagObjectID(hashtagObjectID: NSManagedObjectID)
+
+ case bottomLoader
+}
+
+extension SearchResultItem: Equatable {
+ static func == (lhs: SearchResultItem, rhs: SearchResultItem) -> Bool {
+ switch (lhs, rhs) {
+ case (.hashtag(let tagLeft), .hashtag(let tagRight)):
+ return tagLeft == tagRight
+ case (.account(let accountLeft), .account(let accountRight)):
+ return accountLeft == accountRight
+ case (.bottomLoader, .bottomLoader):
+ return true
+ case (.accountObjectID(let idLeft),.accountObjectID(let idRight)):
+ return idLeft == idRight
+ case (.hashtagObjectID(let idLeft),.hashtagObjectID(let idRight)):
+ return idLeft == idRight
+ default:
+ return false
+ }
+ }
+}
+
+extension SearchResultItem: Hashable {
+ func hash(into hasher: inout Hasher) {
+ switch self {
+ case .account(let account):
+ hasher.combine(account)
+ case .hashtag(let tag):
+ hasher.combine(tag)
+ case .accountObjectID(let id):
+ hasher.combine(id)
+ case .hashtagObjectID(let id):
+ hasher.combine(id)
+ case .bottomLoader:
+ hasher.combine(String(describing: SearchResultItem.bottomLoader.self))
+ }
+ }
+}
diff --git a/Mastodon/Diffiable/Section/RecommendAccountSection.swift b/Mastodon/Diffiable/Section/RecommendAccountSection.swift
index b08c9aba..3ecd4e3b 100644
--- a/Mastodon/Diffiable/Section/RecommendAccountSection.swift
+++ b/Mastodon/Diffiable/Section/RecommendAccountSection.swift
@@ -5,6 +5,8 @@
// Created by sxiaojian on 2021/4/1.
//
+import CoreData
+import CoreDataStack
import Foundation
import MastodonSDK
import UIKit
@@ -15,11 +17,15 @@ enum RecommendAccountSection: Equatable, Hashable {
extension RecommendAccountSection {
static func collectionViewDiffableDataSource(
- for collectionView: UICollectionView
- ) -> UICollectionViewDiffableDataSource {
- UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, account -> UICollectionViewCell? in
+ for collectionView: UICollectionView,
+ delegate: SearchRecommendAccountsCollectionViewCellDelegate,
+ managedObjectContext: NSManagedObjectContext
+ ) -> UICollectionViewDiffableDataSource {
+ UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak delegate] collectionView, indexPath, objectID -> UICollectionViewCell? in
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SearchRecommendAccountsCollectionViewCell.self), for: indexPath) as! SearchRecommendAccountsCollectionViewCell
- cell.config(with: account)
+ let user = managedObjectContext.object(with: objectID) as! MastodonUser
+ cell.delegate = delegate
+ cell.config(with: user)
return cell
}
}
diff --git a/Mastodon/Diffiable/Section/RecomendHashTagSection.swift b/Mastodon/Diffiable/Section/RecommendHashTagSection.swift
similarity index 74%
rename from Mastodon/Diffiable/Section/RecomendHashTagSection.swift
rename to Mastodon/Diffiable/Section/RecommendHashTagSection.swift
index 2f78e73b..50208691 100644
--- a/Mastodon/Diffiable/Section/RecomendHashTagSection.swift
+++ b/Mastodon/Diffiable/Section/RecommendHashTagSection.swift
@@ -1,5 +1,5 @@
//
-// RecomendHashTagSection.swift
+// RecommendHashTagSection.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/1.
@@ -9,14 +9,14 @@ import Foundation
import MastodonSDK
import UIKit
-enum RecomendHashTagSection: Equatable, Hashable {
+enum RecommendHashTagSection: Equatable, Hashable {
case main
}
-extension RecomendHashTagSection {
+extension RecommendHashTagSection {
static func collectionViewDiffableDataSource(
for collectionView: UICollectionView
- ) -> UICollectionViewDiffableDataSource {
+ ) -> 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)
diff --git a/Mastodon/Diffiable/Section/SearchResultSection.swift b/Mastodon/Diffiable/Section/SearchResultSection.swift
new file mode 100644
index 00000000..50c56160
--- /dev/null
+++ b/Mastodon/Diffiable/Section/SearchResultSection.swift
@@ -0,0 +1,53 @@
+//
+// SearchResultSection.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/6.
+//
+
+import Foundation
+import MastodonSDK
+import UIKit
+import CoreData
+import CoreDataStack
+
+enum SearchResultSection: Equatable, Hashable {
+ case account
+ case hashtag
+ case mixed
+ case bottomLoader
+}
+
+extension SearchResultSection {
+ static func tableViewDiffableDataSource(
+ for tableView: UITableView,
+ dependency: NeedsDependency
+ ) -> UITableViewDiffableDataSource {
+ UITableViewDiffableDataSource(tableView: tableView) { (tableView, indexPath, result) -> UITableViewCell? in
+ switch result {
+ case .account(let account):
+ let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
+ cell.config(with: account)
+ return cell
+ case .hashtag(let tag):
+ let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
+ cell.config(with: tag)
+ return cell
+ case .hashtagObjectID(let hashtagObjectID):
+ let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
+ let tag = dependency.context.managedObjectContext.object(with: hashtagObjectID) as! Tag
+ cell.config(with: tag)
+ return cell
+ case .accountObjectID(let accountObjectID):
+ let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
+ let user = dependency.context.managedObjectContext.object(with: accountObjectID) as! MastodonUser
+ cell.config(with: user)
+ return cell
+ case .bottomLoader:
+ let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchBottomLoader.self)) as! SearchBottomLoader
+ cell.startAnimating()
+ return cell
+ }
+ }
+ }
+}
diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift
index 7e217734..843fce02 100644
--- a/Mastodon/Generated/Assets.swift
+++ b/Mastodon/Generated/Assets.swift
@@ -42,8 +42,9 @@ internal enum Asset {
internal static let dangerBorder = ColorAsset(name: "Colors/Background/danger.border")
internal static let danger = ColorAsset(name: "Colors/Background/danger")
internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/media.type.indicotor")
+ internal static let navigationBar = ColorAsset(name: "Colors/Background/navigationBar")
internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background")
- internal static let search = ColorAsset(name: "Colors/Background/search")
+ internal static let searchResult = ColorAsset(name: "Colors/Background/searchResult")
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 systemBackground = ColorAsset(name: "Colors/Background/system.background")
@@ -51,6 +52,9 @@ internal enum Asset {
internal static let tertiarySystemBackground = ColorAsset(name: "Colors/Background/tertiary.system.background")
internal static let tertiarySystemGroupedBackground = ColorAsset(name: "Colors/Background/tertiary.system.grouped.background")
}
+ internal enum Border {
+ internal static let searchCard = ColorAsset(name: "Colors/Border/searchCard")
+ }
internal enum Button {
internal static let actionToolbar = ColorAsset(name: "Colors/Button/action.toolbar")
internal static let disabled = ColorAsset(name: "Colors/Button/disabled")
@@ -66,6 +70,9 @@ internal enum Asset {
internal static let primary = ColorAsset(name: "Colors/Label/primary")
internal static let secondary = ColorAsset(name: "Colors/Label/secondary")
}
+ internal enum Shadow {
+ internal static let searchCard = ColorAsset(name: "Colors/Shadow/SearchCard")
+ }
internal enum Slider {
internal static let bar = ColorAsset(name: "Colors/Slider/bar")
}
diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift
index 4ed28165..685b47e4 100644
--- a/Mastodon/Generated/Strings.swift
+++ b/Mastodon/Generated/Strings.swift
@@ -490,7 +490,7 @@ internal enum L10n {
internal enum Search {
internal enum Recommend {
/// See All
- internal static let buttontext = L10n.tr("Localizable", "Scene.Search.Recommend.Buttontext")
+ 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")
@@ -516,6 +516,20 @@ internal enum L10n {
/// Search hashtags and users
internal static let placeholder = L10n.tr("Localizable", "Scene.Search.Searchbar.Placeholder")
}
+ internal enum Searching {
+ /// clear
+ internal static let clear = L10n.tr("Localizable", "Scene.Search.Searching.Clear")
+ /// Recent searches
+ internal static let recentSearch = L10n.tr("Localizable", "Scene.Search.Searching.RecentSearch")
+ internal enum Segment {
+ /// All
+ internal static let all = L10n.tr("Localizable", "Scene.Search.Searching.Segment.All")
+ /// Hashtags
+ internal static let hashtags = L10n.tr("Localizable", "Scene.Search.Searching.Segment.Hashtags")
+ /// People
+ internal static let people = L10n.tr("Localizable", "Scene.Search.Searching.Segment.People")
+ }
+ }
}
internal enum ServerPicker {
/// Pick a Server,\nany server.
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/navigationBar.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/navigationBar.colorset/Contents.json
new file mode 100644
index 00000000..7f9578a7
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/navigationBar.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "0.940",
+ "blue" : "249",
+ "green" : "249",
+ "red" : "249"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "0.940",
+ "blue" : "29",
+ "green" : "29",
+ "red" : "29"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/search.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/search.colorset/Contents.json
deleted file mode 100644
index 838e44e4..00000000
--- a/Mastodon/Resources/Assets.xcassets/Colors/Background/search.colorset/Contents.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "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/Assets.xcassets/Colors/Background/searchResult.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/searchResult.colorset/Contents.json
new file mode 100644
index 00000000..3338422a
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/searchResult.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xFE",
+ "green" : "0xFF",
+ "red" : "0xFE"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x00",
+ "green" : "0x00",
+ "red" : "0x00"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Border/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Border/Contents.json
new file mode 100644
index 00000000..6e965652
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Colors/Border/Contents.json
@@ -0,0 +1,9 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "provides-namespace" : true
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Border/searchCard.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Border/searchCard.colorset/Contents.json
new file mode 100644
index 00000000..a0ce2efb
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Colors/Border/searchCard.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "0.000",
+ "blue" : "1.000",
+ "green" : "1.000",
+ "red" : "1.000"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "0.300",
+ "blue" : "213",
+ "green" : "213",
+ "red" : "213"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Shadow/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Shadow/Contents.json
new file mode 100644
index 00000000..6e965652
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Colors/Shadow/Contents.json
@@ -0,0 +1,9 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "provides-namespace" : true
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Shadow/SearchCard.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Shadow/SearchCard.colorset/Contents.json
new file mode 100644
index 00000000..a28cf079
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Colors/Shadow/SearchCard.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0",
+ "green" : "0",
+ "red" : "0"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "0.000",
+ "blue" : "1.000",
+ "green" : "1.000",
+ "red" : "1.000"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings
index 6914b608..f8e5c516 100644
--- a/Mastodon/Resources/en.lproj/Localizable.strings
+++ b/Mastodon/Resources/en.lproj/Localizable.strings
@@ -159,12 +159,17 @@ tap the link to confirm your account.";
"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.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.Search.Searching.Clear" = "clear";
+"Scene.Search.Searching.RecentSearch" = "Recent searches";
+"Scene.Search.Searching.Segment.All" = "All";
+"Scene.Search.Searching.Segment.Hashtags" = "Hashtags";
+"Scene.Search.Searching.Segment.People" = "People";
"Scene.ServerPicker.Button.Category.All" = "All";
"Scene.ServerPicker.Button.SeeLess" = "See Less";
"Scene.ServerPicker.Button.SeeMore" = "See More";
diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift
index b4305eef..f4512467 100644
--- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift
+++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift
@@ -5,28 +5,47 @@
// Created by sxiaojian on 2021/4/1.
//
+import Combine
+import CoreDataStack
import Foundation
import MastodonSDK
import UIKit
+protocol SearchRecommendAccountsCollectionViewCellDelegate: NSObject {
+ func followButtonDidPressed(clickedUser: MastodonUser)
+
+ func configFollowButton(with mastodonUser: MastodonUser, followButton: HighlightDimmableButton)
+}
+
class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell {
+ var disposeBag = Set()
+
+ weak var delegate: SearchRecommendAccountsCollectionViewCellDelegate?
+
let avatarImageView: UIImageView = {
let imageView = UIImageView()
- imageView.layer.cornerRadius = 8
+ imageView.layer.cornerRadius = 8.4
imageView.clipsToBounds = true
return imageView
}()
let headerImageView: UIImageView = {
let imageView = UIImageView()
- imageView.layer.cornerRadius = 8
+ imageView.contentMode = .scaleAspectFill
+ imageView.layer.cornerRadius = 10
+ imageView.layer.cornerCurve = .continuous
imageView.clipsToBounds = true
+ imageView.layer.borderWidth = 2
+ imageView.layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor
return imageView
}()
+ let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
+
let displayNameLabel: UILabel = {
let label = UILabel()
label.textColor = .white
+ label.textAlignment = .center
label.font = .systemFont(ofSize: 18, weight: .semibold)
label.translatesAutoresizingMaskIntoConstraints = false
return label
@@ -36,17 +55,19 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell {
let label = UILabel()
label.textColor = .white
label.font = .preferredFont(forTextStyle: .body)
+ label.textAlignment = .center
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
- let followButton: UIButton = {
- let button = UIButton(type: .custom)
+ let followButton: HighlightDimmableButton = {
+ let button = HighlightDimmableButton(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.cornerCurve = .continuous
+ button.layer.borderWidth = 2
button.layer.borderColor = UIColor.white.cgColor
return button
}()
@@ -55,6 +76,8 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell {
super.prepareForReuse()
headerImageView.af.cancelImageRequest()
avatarImageView.af.cancelImageRequest()
+ visualEffectView.removeFromSuperview()
+ disposeBag.removeAll()
}
override init(frame: CGRect) {
@@ -69,11 +92,18 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell {
}
extension SearchRecommendAccountsCollectionViewCell {
+ override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+ super.traitCollectionDidChange(previousTraitCollection)
+ 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)
+ }
+
private func configure() {
headerImageView.backgroundColor = Asset.Colors.brandBlue.color
- layer.cornerRadius = 8
- clipsToBounds = true
-
+ layer.cornerRadius = 10
+ layer.cornerCurve = .continuous
+ clipsToBounds = false
+ applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0)
contentView.addSubview(headerImageView)
headerImageView.pin(top: 16, left: 0, bottom: 0, right: 0)
@@ -87,12 +117,16 @@ extension SearchRecommendAccountsCollectionViewCell {
contentView.addSubview(displayNameLabel)
displayNameLabel.constrain([
displayNameLabel.constraint(.top, toView: contentView, constant: 108),
+ displayNameLabel.constraint(.leading, toView: contentView),
+ displayNameLabel.constraint(.trailing, toView: contentView),
displayNameLabel.constraint(.centerX, toView: contentView)
])
contentView.addSubview(acctLabel)
acctLabel.constrain([
acctLabel.constraint(.top, toView: contentView, constant: 132),
+ acctLabel.constraint(.leading, toView: contentView),
+ acctLabel.constraint(.trailing, toView: contentView),
acctLabel.constraint(.centerX, toView: contentView)
])
@@ -104,19 +138,33 @@ extension SearchRecommendAccountsCollectionViewCell {
])
}
- func config(with account: Mastodon.Entity.Account) {
- displayNameLabel.text = account.displayName.isEmpty ? account.username : account.displayName
- acctLabel.text = account.acct
+ func config(with mastodonUser: MastodonUser) {
+ displayNameLabel.text = mastodonUser.displayName.isEmpty ? mastodonUser.username : mastodonUser.displayName
+ acctLabel.text = mastodonUser.acct
avatarImageView.af.setImage(
- withURL: URL(string: account.avatar)!,
+ withURL: URL(string: mastodonUser.avatar)!,
placeholderImage: UIImage.placeholder(color: .systemFill),
imageTransition: .crossDissolve(0.2)
)
headerImageView.af.setImage(
- withURL: URL(string: account.header)!,
+ withURL: URL(string: mastodonUser.header)!,
placeholderImage: UIImage.placeholder(color: .systemFill),
imageTransition: .crossDissolve(0.2)
- )
+ ) { [weak self] _ in
+ guard let self = self else { return }
+ self.headerImageView.addSubview(self.visualEffectView)
+ self.visualEffectView.pin(top: 0, left: 0, bottom: 0, right: 0)
+ }
+ delegate?.configFollowButton(with: mastodonUser, followButton: followButton)
+ followButton.publisher(for: .touchUpInside)
+ .sink { [weak self] _ in
+ self?.followButtonDidPressed(mastodonUser: mastodonUser)
+ }
+ .store(in: &disposeBag)
+ }
+
+ func followButtonDidPressed(mastodonUser: MastodonUser) {
+ delegate?.followButtonDidPressed(clickedUser: mastodonUser)
}
}
diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift
index 8088d1b0..d00cb050 100644
--- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift
+++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift
@@ -15,8 +15,8 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell {
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
}()
-
- let hashTagTitleLabel: UILabel = {
+
+ let hashtagTitleLabel: UILabel = {
let label = UILabel()
label.textColor = .white
label.font = .systemFont(ofSize: 20, weight: .semibold)
@@ -58,16 +58,27 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell {
}
extension SearchRecommendTagsCollectionViewCell {
+
+ override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+ super.traitCollectionDidChange(previousTraitCollection)
+ 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)
+ }
+
private func configure() {
backgroundColor = Asset.Colors.brandBlue.color
- layer.cornerRadius = 8
- clipsToBounds = true
+ layer.cornerRadius = 10
+ layer.cornerCurve = .continuous
+ clipsToBounds = false
+ layer.borderWidth = 2
+ 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)
contentView.addSubview(backgroundImageView)
backgroundImageView.constrain(toSuperviewEdges: nil)
- contentView.addSubview(hashTagTitleLabel)
- hashTagTitleLabel.pin(top: 16, left: 16, bottom: nil, right: 42)
+ contentView.addSubview(hashtagTitleLabel)
+ hashtagTitleLabel.pin(top: 16, left: 16, bottom: nil, right: 42)
contentView.addSubview(peopleLabel)
peopleLabel.pinTopLeft(top: 46, left: 16)
@@ -77,19 +88,13 @@ extension SearchRecommendTagsCollectionViewCell {
}
func config(with tag: Mastodon.Entity.Tag) {
- hashTagTitleLabel.text = "# " + tag.name
+ 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 recentHistory = historys.prefix(2)
let peopleAreTalking = recentHistory.compactMap({ Int($0.accounts) }).reduce(0, +)
let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking))
peopleLabel.text = string
@@ -105,7 +110,7 @@ struct SearchRecommendTagsCollectionViewCell_Previews: PreviewProvider {
Group {
UIViewPreview {
let cell = SearchRecommendTagsCollectionViewCell()
- cell.hashTagTitleLabel.text = "# test"
+ cell.hashtagTitleLabel.text = "# test"
cell.peopleLabel.text = "128 people are talking"
return cell
}
diff --git a/Mastodon/Scene/Search/SearchViewController+Follow.swift b/Mastodon/Scene/Search/SearchViewController+Follow.swift
new file mode 100644
index 00000000..8b0acda0
--- /dev/null
+++ b/Mastodon/Scene/Search/SearchViewController+Follow.swift
@@ -0,0 +1,137 @@
+//
+// SearchViewController+Follow.swift
+// Mastodon
+//
+// Created by xiaojian sun on 2021/4/9.
+//
+
+import Combine
+import CoreDataStack
+import Foundation
+import UIKit
+
+extension SearchViewController: UserProvider {
+ func mastodonUser() -> Future {
+ Future { promise in
+ promise(.success(self.viewModel.mastodonUser.value))
+ }
+ }
+}
+
+extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegate {
+ func followButtonDidPressed(clickedUser: MastodonUser) {
+ viewModel.mastodonUser.value = clickedUser
+ guard let currentMastodonUser = viewModel.currentMastodonUser.value else {
+ return
+ }
+ guard let relationshipAction = relationShipActionSet(mastodonUser: clickedUser, currentMastodonUser: currentMastodonUser).highPriorityAction(except: .editOptions) else { return }
+ switch relationshipAction {
+ case .none:
+ break
+ case .follow, .following:
+ UserProviderFacade.toggleUserFollowRelationship(provider: self)
+ .sink { _ in
+
+ } receiveValue: { _ in
+ }
+ .store(in: &disposeBag)
+ case .pending:
+ break
+ case .muting:
+ guard let mastodonUser = viewModel.mastodonUser.value else { return }
+ let name = mastodonUser.displayNameWithFallback
+ let alertController = UIAlertController(
+ title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.title,
+ message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.message(name),
+ preferredStyle: .alert
+ )
+ let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Firendship.unmute, style: .default) { [weak self] _ in
+ guard let self = self else { return }
+ UserProviderFacade.toggleUserMuteRelationship(provider: self)
+ .sink { _ in
+ // do nothing
+ } receiveValue: { _ in
+ // do nothing
+ }
+ .store(in: &self.context.disposeBag)
+ }
+ alertController.addAction(unmuteAction)
+ let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil)
+ alertController.addAction(cancelAction)
+ present(alertController, animated: true, completion: nil)
+ case .blocking:
+ guard let mastodonUser = viewModel.mastodonUser.value else { return }
+ let name = mastodonUser.displayNameWithFallback
+ let alertController = UIAlertController(
+ title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.title,
+ message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.message(name),
+ preferredStyle: .alert
+ )
+ let unblockAction = UIAlertAction(title: L10n.Common.Controls.Firendship.unblock, style: .default) { [weak self] _ in
+ guard let self = self else { return }
+ UserProviderFacade.toggleUserBlockRelationship(provider: self)
+ .sink { _ in
+ // do nothing
+ } receiveValue: { _ in
+ // do nothing
+ }
+ .store(in: &self.context.disposeBag)
+ }
+ alertController.addAction(unblockAction)
+ let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil)
+ alertController.addAction(cancelAction)
+ present(alertController, animated: true, completion: nil)
+ case .blocked:
+ break
+ default:
+ assertionFailure()
+ }
+ }
+
+ func configFollowButton(with mastodonUser: MastodonUser, followButton: HighlightDimmableButton) {
+ guard let currentMastodonUser = viewModel.currentMastodonUser.value else {
+ return
+ }
+ _configFollowButton(with: mastodonUser, currentMastodonUser: currentMastodonUser, followButton: followButton)
+ ManagedObjectObserver.observe(object: currentMastodonUser)
+ .sink { _ in
+
+ } receiveValue: { change in
+ guard case .update(let object) = change.changeType,
+ let newUser = object as? MastodonUser else { return }
+ self._configFollowButton(with: mastodonUser, currentMastodonUser: newUser, followButton: followButton)
+ }
+ .store(in: &disposeBag)
+ }
+}
+
+extension SearchViewController {
+ func _configFollowButton(with mastodonUser: MastodonUser, currentMastodonUser: MastodonUser, followButton: HighlightDimmableButton) {
+ let relationshipActionSet = relationShipActionSet(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser)
+ followButton.setTitle(relationshipActionSet.title, for: .normal)
+ }
+
+ func relationShipActionSet(mastodonUser: MastodonUser, currentMastodonUser: MastodonUser) -> ProfileViewModel.RelationshipActionOptionSet {
+ var relationshipActionSet = ProfileViewModel.RelationshipActionOptionSet([.follow])
+ let isFollowing = mastodonUser.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
+ if isFollowing {
+ relationshipActionSet.insert(.following)
+ }
+
+ let isPending = mastodonUser.followRequestedBy.flatMap { $0.contains(currentMastodonUser) } ?? false
+ if isPending {
+ relationshipActionSet.insert(.pending)
+ }
+
+ let isBlocking = mastodonUser.blockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
+ if isBlocking {
+ relationshipActionSet.insert(.blocking)
+ }
+
+ let isBlockedBy = currentMastodonUser.blockingBy.flatMap { $0.contains(mastodonUser) } ?? false
+ if isBlockedBy {
+ relationshipActionSet.insert(.blocked)
+ }
+ return relationshipActionSet
+ }
+}
diff --git a/Mastodon/Scene/Search/SearchViewController+RecomendView.swift b/Mastodon/Scene/Search/SearchViewController+Recommend.swift
similarity index 57%
rename from Mastodon/Scene/Search/SearchViewController+RecomendView.swift
rename to Mastodon/Scene/Search/SearchViewController+Recommend.swift
index ca373b6b..e941fa84 100644
--- a/Mastodon/Scene/Search/SearchViewController+RecomendView.swift
+++ b/Mastodon/Scene/Search/SearchViewController+Recommend.swift
@@ -1,10 +1,12 @@
//
-// SearchViewController+RecomendView.swift
+// SearchViewController+Recommend.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/31.
//
+import CoreData
+import CoreDataStack
import Foundation
import MastodonSDK
import OSLog
@@ -15,32 +17,16 @@ extension SearchViewController {
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)
+ 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
+ hashtagCollectionView.register(SearchRecommendTagsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self))
+ hashtagCollectionView.delegate = self
- stackView.addArrangedSubview(hashTagCollectionView)
- hashTagCollectionView.constrain([
- hashTagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 130)
+ 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() {
@@ -57,27 +43,11 @@ extension SearchViewController {
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()
+ hashtagCollectionView.collectionViewLayout.invalidateLayout()
accountsCollectionView.collectionViewLayout.invalidateLayout()
}
}
@@ -86,6 +56,19 @@ 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)
+ switch collectionView {
+ case self.accountsCollectionView:
+ guard let diffableDataSource = viewModel.accountDiffableDataSource else { return }
+ guard let accountObjectID = diffableDataSource.itemIdentifier(for: indexPath) else { return }
+ let user = context.managedObjectContext.object(with: accountObjectID) as! MastodonUser
+ viewModel.accountCollectionViewItemDidSelected(mastodonUser: user, from: self)
+ case self.hashtagCollectionView:
+ guard let diffableDataSource = viewModel.hashtagDiffableDataSource else { return }
+ guard let hashtag = diffableDataSource.itemIdentifier(for: indexPath) else { return }
+ viewModel.hashtagCollectionViewItemDidSelected(hashtag: hashtag, from: self)
+ default:
+ break
+ }
}
}
@@ -97,7 +80,7 @@ extension SearchViewController: UICollectionViewDelegateFlowLayout {
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
- if collectionView == hashTagCollectionView {
+ if collectionView == hashtagCollectionView {
return 6
} else {
return 12
@@ -105,7 +88,7 @@ extension SearchViewController: UICollectionViewDelegateFlowLayout {
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
- if collectionView == hashTagCollectionView {
+ if collectionView == hashtagCollectionView {
return CGSize(width: 228, height: 130)
} else {
return CGSize(width: 257, height: 202)
@@ -114,7 +97,7 @@ extension SearchViewController: UICollectionViewDelegateFlowLayout {
}
extension SearchViewController {
- @objc func hashTagSeeAllButtonPressed(_ sender: UIButton) {}
+ @objc func hashtagSeeAllButtonPressed(_ sender: UIButton) {}
@objc func accountSeeAllButtonPressed(_ sender: UIButton) {}
}
diff --git a/Mastodon/Scene/Search/SearchViewController+Searching.swift b/Mastodon/Scene/Search/SearchViewController+Searching.swift
new file mode 100644
index 00000000..3eb9793a
--- /dev/null
+++ b/Mastodon/Scene/Search/SearchViewController+Searching.swift
@@ -0,0 +1,91 @@
+//
+// SearchViewController+Searching.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/2.
+//
+
+import Combine
+import CoreData
+import CoreDataStack
+import Foundation
+import MastodonSDK
+import OSLog
+import UIKit
+
+extension SearchViewController {
+ func setupSearchingTableView() {
+ searchingTableView.delegate = self
+ searchingTableView.register(SearchingTableViewCell.self, forCellReuseIdentifier: String(describing: SearchingTableViewCell.self))
+ searchingTableView.register(SearchBottomLoader.self, forCellReuseIdentifier: String(describing: SearchBottomLoader.self))
+ view.addSubview(searchingTableView)
+ searchingTableView.constrain([
+ searchingTableView.frameLayoutGuide.topAnchor.constraint(equalTo: searchBar.bottomAnchor),
+ searchingTableView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
+ searchingTableView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
+ searchingTableView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
+ searchingTableView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor)
+ ])
+ searchingTableView.tableFooterView = UIView()
+ viewModel.isSearching
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] isSearching in
+ self?.searchingTableView.isHidden = !isSearching
+ }
+ .store(in: &disposeBag)
+
+ Publishers.CombineLatest(
+ viewModel.isSearching,
+ viewModel.searchText
+ )
+ .sink { [weak self] isSearching, text in
+ guard let self = self else { return }
+ if isSearching, text.isEmpty {
+ self.searchingTableView.tableHeaderView = self.searchHeader
+ } else {
+ self.searchingTableView.tableHeaderView = nil
+ }
+ }
+ .store(in: &disposeBag)
+ }
+
+ func setupSearchHeader() {
+ searchHeader.addSubview(recentSearchesLabel)
+ recentSearchesLabel.constrain([
+ recentSearchesLabel.constraint(.leading, toView: searchHeader, constant: 16),
+ recentSearchesLabel.constraint(.centerY, toView: searchHeader)
+ ])
+
+ searchHeader.addSubview(clearSearchHistoryButton)
+ recentSearchesLabel.constrain([
+ searchHeader.trailingAnchor.constraint(equalTo: clearSearchHistoryButton.trailingAnchor, constant: 16),
+ clearSearchHistoryButton.constraint(.centerY, toView: searchHeader)
+ ])
+
+ clearSearchHistoryButton.addTarget(self, action: #selector(SearchViewController.clearAction(_:)), for: .touchUpInside)
+ }
+}
+
+extension SearchViewController {
+ @objc func clearAction(_ sender: UIButton) {
+ viewModel.deleteSearchHistory()
+ }
+}
+
+// MARK: - UITableViewDelegate
+
+extension SearchViewController: UITableViewDelegate {
+ func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
+ 66
+ }
+
+ func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
+ 66
+ }
+
+ func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ guard let diffableDataSource = viewModel.searchResultDiffableDataSource else { return }
+ guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
+ viewModel.searchResultItemDidSelected(item: item, from: self)
+ }
+}
diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift
index fbf56513..5dcc47e0 100644
--- a/Mastodon/Scene/Search/SearchViewController.swift
+++ b/Mastodon/Scene/Search/SearchViewController.swift
@@ -6,6 +6,7 @@
//
import Combine
+import GameplayKit
import MastodonSDK
import UIKit
@@ -14,7 +15,13 @@ final class SearchViewController: UIViewController, NeedsDependency {
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set()
- private(set) lazy var viewModel = SearchViewModel(context: context)
+ private(set) lazy var viewModel = SearchViewModel(context: context, coordinator: coordinator)
+
+ let statusBar: UIView = {
+ let view = UIView()
+ view.backgroundColor = Asset.Colors.Background.navigationBar.color
+ return view
+ }()
let searchBar: UISearchBar = {
let searchBar = UISearchBar()
@@ -24,9 +31,12 @@ final class SearchViewController: UIViewController, NeedsDependency {
let micImage = UIImage(systemName: "mic.fill")
searchBar.setImage(micImage, for: .bookmark, state: .normal)
searchBar.showsBookmarkButton = true
+ searchBar.scopeButtonTitles = [L10n.Scene.Search.Searching.Segment.all, L10n.Scene.Search.Searching.Segment.people, L10n.Scene.Search.Searching.Segment.hashtags]
+ searchBar.barTintColor = Asset.Colors.Background.navigationBar.color
return searchBar
}()
+ // recommend
let scrollView: UIScrollView = {
let scrollView = UIScrollView()
scrollView.showsVerticalScrollIndicator = false
@@ -45,7 +55,7 @@ final class SearchViewController: UIViewController, NeedsDependency {
return stackView
}()
- let hashTagCollectionView: UICollectionView = {
+ let hashtagCollectionView: UICollectionView = {
let flowLayout = UICollectionViewFlowLayout()
flowLayout.scrollDirection = .horizontal
let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout)
@@ -56,9 +66,6 @@ final class SearchViewController: UIViewController, NeedsDependency {
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
-
- var hashTagDiffableDataSource: UICollectionViewDiffableDataSource?
- var accountDiffableDataSource: UICollectionViewDiffableDataSource?
let accountsCollectionView: UICollectionView = {
let flowLayout = UICollectionViewFlowLayout()
@@ -71,24 +78,82 @@ final class SearchViewController: UIViewController, NeedsDependency {
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
+
+ // searching
+ let searchingTableView: UITableView = {
+ let tableView = UITableView()
+ tableView.backgroundColor = Asset.Colors.Background.searchResult.color
+ tableView.rowHeight = UITableView.automaticDimension
+ tableView.separatorStyle = .singleLine
+ tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
+ return tableView
+ }()
+
+ lazy var searchHeader: UIView = {
+ let view = UIView()
+ view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
+ view.frame = CGRect(origin: .zero, size: CGSize(width: searchingTableView.frame.width, height: 56))
+ return view
+ }()
+
+ let recentSearchesLabel: UILabel = {
+ let label = UILabel()
+ label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
+ label.textColor = Asset.Colors.Label.primary.color
+ label.text = L10n.Scene.Search.Searching.recentSearch
+ return label
+ }()
+
+ let clearSearchHistoryButton: HighlightDimmableButton = {
+ let button = HighlightDimmableButton(type: .custom)
+ button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
+ button.setTitle(L10n.Scene.Search.Searching.clear, for: .normal)
+ return button
+ }()
}
extension SearchViewController {
override func viewDidLoad() {
super.viewDidLoad()
- view.backgroundColor = Asset.Colors.Background.search.color
- searchBar.delegate = self
- navigationItem.titleView = searchBar
+ let barAppearance = UINavigationBarAppearance()
+ barAppearance.configureWithTransparentBackground()
+ navigationItem.standardAppearance = barAppearance
+ navigationItem.compactAppearance = barAppearance
+ navigationItem.scrollEdgeAppearance = barAppearance
+ view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
navigationItem.hidesBackButton = true
+
+ setupSearchBar()
setupScrollView()
setupHashTagCollectionView()
setupAccountsCollectionView()
+ setupSearchingTableView()
+ setupDataSource()
+ setupSearchHeader()
+ }
+
+ func setupSearchBar() {
+ searchBar.delegate = self
+ view.addSubview(searchBar)
+ searchBar.constrain([
+ searchBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
+ searchBar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
+ searchBar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
+ ])
+ view.addSubview(statusBar)
+
+ statusBar.constrain([
+ statusBar.topAnchor.constraint(equalTo: view.topAnchor),
+ statusBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ statusBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ statusBar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 3),
+ ])
}
func setupScrollView() {
view.addSubview(scrollView)
scrollView.constrain([
- scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
+ scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: searchBar.bottomAnchor),
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
@@ -104,30 +169,70 @@ extension SearchViewController {
scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: stackView.bottomAnchor),
])
}
+
+ func setupDataSource() {
+ viewModel.hashtagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashtagCollectionView)
+ viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView, delegate: self, managedObjectContext: context.managedObjectContext)
+ viewModel.searchResultDiffableDataSource = SearchResultSection.tableViewDiffableDataSource(for: searchingTableView, dependency: self)
+ }
+}
+
+extension SearchViewController: UIScrollViewDelegate {
+ func scrollViewDidScroll(_ scrollView: UIScrollView) {
+ if scrollView == searchingTableView {
+ handleScrollViewDidScroll(scrollView)
+ }
+ }
}
extension SearchViewController: UISearchBarDelegate {
func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
searchBar.setShowsCancelButton(true, animated: true)
+ searchBar.showsScopeBar = true
+ viewModel.isSearching.value = true
}
func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
searchBar.setShowsCancelButton(false, animated: true)
+ searchBar.showsScopeBar = false
+ viewModel.isSearching.value = true
}
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
searchBar.setShowsCancelButton(false, animated: true)
+ searchBar.showsScopeBar = false
searchBar.text = ""
searchBar.resignFirstResponder()
+ viewModel.isSearching.value = false
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
viewModel.searchText.send(searchText)
}
+ func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
+ switch selectedScope {
+ case 0:
+ viewModel.searchScope.value = Mastodon.API.Search.SearchType.default
+ case 1:
+ viewModel.searchScope.value = Mastodon.API.Search.SearchType.accounts
+ case 2:
+ viewModel.searchScope.value = Mastodon.API.Search.SearchType.hashtags
+ default:
+ break
+ }
+ }
+
func searchBarBookmarkButtonClicked(_ searchBar: UISearchBar) {}
}
+extension SearchViewController: LoadMoreConfigurableTableViewContainer {
+ typealias BottomLoaderTableViewCell = SearchBottomLoader
+ typealias LoadingState = SearchViewModel.LoadOldestState.Loading
+ var loadMoreConfigurableTableView: UITableView { searchingTableView }
+ var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadoldestStateMachine }
+}
+
#if canImport(SwiftUI) && DEBUG
import SwiftUI
diff --git a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift
new file mode 100644
index 00000000..c76ab202
--- /dev/null
+++ b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift
@@ -0,0 +1,144 @@
+//
+// SearchViewModel+LoadOldestState.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/6.
+//
+
+import Foundation
+import GameplayKit
+import MastodonSDK
+import os.log
+
+extension SearchViewModel {
+ class LoadOldestState: GKState {
+ weak var viewModel: SearchViewModel?
+
+ init(viewModel: SearchViewModel) {
+ self.viewModel = viewModel
+ }
+
+ override func didEnter(from previousState: GKState?) {
+ os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", (#file as NSString).lastPathComponent, #line, #function, debugDescription, previousState.debugDescription)
+ viewModel?.loadOldestStateMachinePublisher.send(self)
+ }
+ }
+}
+
+extension SearchViewModel.LoadOldestState {
+ class Initial: SearchViewModel.LoadOldestState {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ guard let viewModel = viewModel else { return false }
+ guard viewModel.searchResult.value != nil else { return false }
+ return stateClass == Loading.self
+ }
+ }
+
+ class Loading: SearchViewModel.LoadOldestState {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self
+ }
+
+ override func didEnter(from previousState: GKState?) {
+ super.didEnter(from: previousState)
+ guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
+ guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
+ assertionFailure()
+ stateMachine.enter(Fail.self)
+ return
+ }
+ guard let oldSearchResult = viewModel.searchResult.value else {
+ stateMachine.enter(Fail.self)
+ return
+ }
+ var offset = 0
+ switch viewModel.searchScope.value {
+ case Mastodon.API.Search.SearchType.accounts:
+ offset = oldSearchResult.accounts.count
+ case Mastodon.API.Search.SearchType.hashtags:
+ offset = oldSearchResult.hashtags.count
+ default:
+ return
+ }
+ let query = Mastodon.API.Search.Query(q: viewModel.searchText.value,
+ type: viewModel.searchScope.value,
+ accountID: nil,
+ maxID: nil,
+ minID: nil,
+ excludeUnreviewed: nil,
+ resolve: nil,
+ limit: nil,
+ offset: offset,
+ following: nil)
+
+ viewModel.context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
+ .sink { completion in
+ switch completion {
+ case .failure(let error):
+ os_log("%{public}s[%{public}ld], %{public}s: load oldest search failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
+ case .finished:
+ // handle isFetchingLatestTimeline in fetch controller delegate
+ break
+ }
+ } receiveValue: { result in
+ switch viewModel.searchScope.value {
+ case Mastodon.API.Search.SearchType.accounts:
+ if result.value.accounts.isEmpty {
+ stateMachine.enter(NoMore.self)
+ } else {
+ var newAccounts = [Mastodon.Entity.Account]()
+ newAccounts.append(contentsOf: oldSearchResult.accounts)
+ newAccounts.append(contentsOf: result.value.accounts)
+ newAccounts.removeDuplicates()
+ viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: newAccounts, statuses: oldSearchResult.statuses, hashtags: oldSearchResult.hashtags)
+ stateMachine.enter(Idle.self)
+ }
+ case Mastodon.API.Search.SearchType.hashtags:
+ if result.value.hashtags.isEmpty {
+ stateMachine.enter(NoMore.self)
+ } else {
+ var newTags = [Mastodon.Entity.Tag]()
+ newTags.append(contentsOf: oldSearchResult.hashtags)
+ newTags.append(contentsOf: result.value.hashtags)
+ newTags.removeDuplicates()
+ viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: oldSearchResult.accounts, statuses: oldSearchResult.statuses, hashtags: newTags)
+ stateMachine.enter(Idle.self)
+ }
+ default:
+ return
+ }
+ }
+ .store(in: &viewModel.disposeBag)
+ }
+ }
+
+ class Fail: SearchViewModel.LoadOldestState {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ stateClass == Loading.self || stateClass == Idle.self
+ }
+ }
+
+ class Idle: SearchViewModel.LoadOldestState {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ stateClass == Loading.self
+ }
+ }
+
+ class NoMore: SearchViewModel.LoadOldestState {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ // reset state if needs
+ stateClass == Idle.self
+ }
+
+ override func didEnter(from previousState: GKState?) {
+ guard let viewModel = viewModel else { return }
+ guard let diffableDataSource = viewModel.searchResultDiffableDataSource else {
+ assertionFailure()
+ return
+ }
+ var snapshot = diffableDataSource.snapshot()
+ snapshot.deleteItems([.bottomLoader])
+ diffableDataSource.apply(snapshot)
+ }
+ }
+}
diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift
index 40b22c88..1d87629b 100644
--- a/Mastodon/Scene/Search/SearchViewModel.swift
+++ b/Mastodon/Scene/Search/SearchViewModel.swift
@@ -6,25 +6,201 @@
//
import Combine
+import CoreData
+import CoreDataStack
import Foundation
+import GameplayKit
import MastodonSDK
import OSLog
import UIKit
-final class SearchViewModel {
+final class SearchViewModel: NSObject {
var disposeBag = Set()
// input
let context: AppContext
+ weak var coordinator: SceneCoordinator!
+
+ let mastodonUser = CurrentValueSubject(nil)
+ let currentMastodonUser = CurrentValueSubject(nil)
// output
let searchText = CurrentValueSubject("")
+ let searchScope = CurrentValueSubject(Mastodon.API.Search.SearchType.default)
+
+ let isSearching = CurrentValueSubject(false)
+
+ let searchResult = CurrentValueSubject(nil)
var recommendHashTags = [Mastodon.Entity.Tag]()
- var recommendAccounts = [Mastodon.Entity.Account]()
+ var recommendAccounts = [NSManagedObjectID]()
- init(context: AppContext) {
+ var hashtagDiffableDataSource: UICollectionViewDiffableDataSource?
+ var accountDiffableDataSource: UICollectionViewDiffableDataSource?
+ var searchResultDiffableDataSource: UITableViewDiffableDataSource?
+
+ // bottom loader
+ private(set) lazy var loadoldestStateMachine: GKStateMachine = {
+ // exclude timeline middle fetcher state
+ let stateMachine = GKStateMachine(states: [
+ LoadOldestState.Initial(viewModel: self),
+ LoadOldestState.Loading(viewModel: self),
+ LoadOldestState.Fail(viewModel: self),
+ LoadOldestState.Idle(viewModel: self),
+ LoadOldestState.NoMore(viewModel: self),
+ ])
+ stateMachine.enter(LoadOldestState.Initial.self)
+ return stateMachine
+ }()
+
+ lazy var loadOldestStateMachinePublisher = CurrentValueSubject(nil)
+
+ init(context: AppContext, coordinator: SceneCoordinator) {
+ self.coordinator = coordinator
self.context = context
+ super.init()
+
+ guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
+ return
+ }
+
+ // bind active authentication
+ 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)
+
+ Publishers.CombineLatest(
+ searchText
+ .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(),
+ searchScope
+ )
+ .filter { text, _ in
+ !text.isEmpty
+ }
+ .flatMap { (text, scope) -> AnyPublisher, Error> in
+
+ let query = Mastodon.API.Search.Query(q: text,
+ type: scope,
+ accountID: nil,
+ maxID: nil,
+ minID: nil,
+ excludeUnreviewed: nil,
+ resolve: nil,
+ limit: nil,
+ offset: nil,
+ following: nil)
+ return context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
+ }
+ .sink { _ in
+ } receiveValue: { [weak self] result in
+ self?.searchResult.value = result.value
+ }
+ .store(in: &disposeBag)
+
+ isSearching
+ .sink { [weak self] isSearching in
+ if !isSearching {
+ self?.searchResult.value = nil
+ self?.searchText.value = ""
+ }
+ }
+ .store(in: &disposeBag)
+
+ Publishers.CombineLatest3(
+ isSearching,
+ searchText,
+ searchScope
+ )
+ .filter { isSearching, _, _ in
+ isSearching
+ }
+ .sink { [weak self] _, text, scope in
+ guard text.isEmpty else { return }
+ guard let self = self else { return }
+ guard let searchHistories = self.fetchSearchHistory() else { return }
+ guard let dataSource = self.searchResultDiffableDataSource else { return }
+ var snapshot = NSDiffableDataSourceSnapshot()
+ snapshot.appendSections([.mixed])
+
+ searchHistories.forEach { searchHistory in
+ let containsAccount = scope == Mastodon.API.Search.SearchType.accounts || scope == Mastodon.API.Search.SearchType.default
+ let containsHashTag = scope == Mastodon.API.Search.SearchType.hashtags || scope == Mastodon.API.Search.SearchType.default
+ if let mastodonUser = searchHistory.account, containsAccount {
+ let item = SearchResultItem.accountObjectID(accountObjectID: mastodonUser.objectID)
+ snapshot.appendItems([item], toSection: .mixed)
+ }
+ if let tag = searchHistory.hashtag, containsHashTag {
+ let item = SearchResultItem.hashtagObjectID(hashtagObjectID: tag.objectID)
+ snapshot.appendItems([item], toSection: .mixed)
+ }
+ }
+ dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
+
+ }
+ .store(in: &disposeBag)
+
+ requestRecommendHashTags()
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] _ in
+ guard let self = self else { return }
+ if !self.recommendHashTags.isEmpty {
+ guard let dataSource = self.hashtagDiffableDataSource else { return }
+ var snapshot = NSDiffableDataSourceSnapshot()
+ snapshot.appendSections([.main])
+ snapshot.appendItems(self.recommendHashTags, toSection: .main)
+ dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
+ }
+ } receiveValue: { _ in
+ }
+ .store(in: &disposeBag)
+
+ requestRecommendAccounts()
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] _ in
+ guard let self = self else { return }
+ if !self.recommendAccounts.isEmpty {
+ guard let dataSource = self.accountDiffableDataSource else { return }
+ var snapshot = NSDiffableDataSourceSnapshot()
+ snapshot.appendSections([.main])
+ snapshot.appendItems(self.recommendAccounts, toSection: .main)
+ dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
+ }
+ } receiveValue: { _ in
+ }
+ .store(in: &disposeBag)
+
+ searchResult
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] searchResult in
+ guard let self = self else { return }
+ guard let dataSource = self.searchResultDiffableDataSource else { return }
+ var snapshot = NSDiffableDataSourceSnapshot()
+ if let accounts = searchResult?.accounts {
+ snapshot.appendSections([.account])
+ let items = accounts.compactMap { SearchResultItem.account(account: $0) }
+ snapshot.appendItems(items, toSection: .account)
+ if self.searchScope.value == Mastodon.API.Search.SearchType.accounts, !items.isEmpty {
+ snapshot.appendItems([.bottomLoader], toSection: .account)
+ }
+ }
+ if let tags = searchResult?.hashtags {
+ snapshot.appendSections([.hashtag])
+ let items = tags.compactMap { SearchResultItem.hashtag(tag: $0) }
+ snapshot.appendItems(items, toSection: .hashtag)
+ if self.searchScope.value == Mastodon.API.Search.SearchType.hashtags, !items.isEmpty {
+ snapshot.appendItems([.bottomLoader], toSection: .hashtag)
+ }
+ }
+ dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
+ }
+ .store(in: &disposeBag)
}
func requestRecommendHashTags() -> Future {
@@ -69,9 +245,162 @@ final class SearchViewModel {
}
} receiveValue: { [weak self] accounts in
guard let self = self else { return }
- self.recommendAccounts = accounts.value
+ let ids = accounts.value.compactMap({$0.id})
+ 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 {
+ self.recommendAccounts = users.map(\.objectID)
+ }
}
.store(in: &self.disposeBag)
}
}
+
+ func accountCollectionViewItemDidSelected(mastodonUser: MastodonUser, from: UIViewController) {
+ let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser)
+ DispatchQueue.main.async {
+ self.coordinator.present(scene: .profile(viewModel: viewModel), from: from, transition: .show)
+ }
+ }
+
+ func hashtagCollectionViewItemDidSelected(hashtag: Mastodon.Entity.Tag, from: UIViewController) {
+ let (tagInCoreData, _) = APIService.CoreData.createOrMergeTag(into: context.managedObjectContext, entity: hashtag)
+ let viewModel = HashtagTimelineViewModel(context: context, hashtag: tagInCoreData.name)
+ DispatchQueue.main.async {
+ self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: from, transition: .show)
+ }
+ }
+
+ func searchResultItemDidSelected(item: SearchResultItem, from: UIViewController) {
+ let searchHistories = fetchSearchHistory()
+ _ = context.managedObjectContext.performChanges { [weak self] in
+ guard let self = self else { return }
+ switch item {
+ case .account(let account):
+ guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
+ return
+ }
+ // load request mastodon user
+ let requestMastodonUser: MastodonUser? = {
+ let request = MastodonUser.sortedFetchRequest
+ request.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, id: activeMastodonAuthenticationBox.userID)
+ request.fetchLimit = 1
+ request.returnsObjectsAsFaults = false
+ do {
+ return try self.context.managedObjectContext.fetch(request).first
+ } catch {
+ assertionFailure(error.localizedDescription)
+ return nil
+ }
+ }()
+ let (mastodonUser, _) = APIService.CoreData.createOrMergeMastodonUser(into: self.context.managedObjectContext, for: requestMastodonUser, in: activeMastodonAuthenticationBox.domain, entity: account, userCache: nil, networkDate: Date(), log: OSLog.api)
+ if let searchHistories = searchHistories {
+ let history = searchHistories.first { history -> Bool in
+ guard let account = history.account else { return false }
+ return account.objectID == mastodonUser.objectID
+ }
+ if let history = history {
+ history.update(updatedAt: Date())
+ } else {
+ SearchHistory.insert(into: self.context.managedObjectContext, account: mastodonUser)
+ }
+ } else {
+ SearchHistory.insert(into: self.context.managedObjectContext, account: mastodonUser)
+ }
+ let viewModel = ProfileViewModel(context: self.context, optionalMastodonUser: mastodonUser)
+ DispatchQueue.main.async {
+ self.coordinator.present(scene: .profile(viewModel: viewModel), from: from, transition: .show)
+ }
+
+ case .hashtag(let tag):
+ let (tagInCoreData, _) = APIService.CoreData.createOrMergeTag(into: self.context.managedObjectContext, entity: tag)
+ if let searchHistories = searchHistories {
+ let history = searchHistories.first { history -> Bool in
+ guard let hashtag = history.hashtag else { return false }
+ return hashtag.objectID == tagInCoreData.objectID
+ }
+ if let history = history {
+ history.update(updatedAt: Date())
+ } else {
+ SearchHistory.insert(into: self.context.managedObjectContext, hashtag: tagInCoreData)
+ }
+ } else {
+ SearchHistory.insert(into: self.context.managedObjectContext, hashtag: tagInCoreData)
+ }
+ let viewModel = HashtagTimelineViewModel(context: self.context, hashtag: tagInCoreData.name)
+ DispatchQueue.main.async {
+ self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: from, transition: .show)
+ }
+ case .accountObjectID(let accountObjectID):
+ if let searchHistories = searchHistories {
+ let history = searchHistories.first { history -> Bool in
+ guard let account = history.account else { return false }
+ return account.objectID == accountObjectID
+ }
+ if let history = history {
+ history.update(updatedAt: Date())
+ }
+ }
+ let mastodonUser = self.context.managedObjectContext.object(with: accountObjectID) as! MastodonUser
+ let viewModel = ProfileViewModel(context: self.context, optionalMastodonUser: mastodonUser)
+ DispatchQueue.main.async {
+ self.coordinator.present(scene: .profile(viewModel: viewModel), from: from, transition: .show)
+ }
+ case .hashtagObjectID(let hashtagObjectID):
+ if let searchHistories = searchHistories {
+ let history = searchHistories.first { history -> Bool in
+ guard let hashtag = history.hashtag else { return false }
+ return hashtag.objectID == hashtagObjectID
+ }
+ if let history = history {
+ history.update(updatedAt: Date())
+ }
+ }
+ let tagInCoreData = self.context.managedObjectContext.object(with: hashtagObjectID) as! Tag
+ let viewModel = HashtagTimelineViewModel(context: self.context, hashtag: tagInCoreData.name)
+ DispatchQueue.main.async {
+ self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: from, transition: .show)
+ }
+ default:
+ break
+ }
+ }
+ }
+
+ func fetchSearchHistory() -> [SearchHistory]? {
+ let searchHistory: [SearchHistory]? = {
+ let request = SearchHistory.sortedFetchRequest
+ request.predicate = nil
+ request.returnsObjectsAsFaults = false
+ do {
+ return try context.managedObjectContext.fetch(request)
+ } catch {
+ assertionFailure(error.localizedDescription)
+ return nil
+ }
+
+ }()
+ return searchHistory
+ }
+
+ func deleteSearchHistory() {
+ let result = fetchSearchHistory()
+ _ = context.managedObjectContext.performChanges { [weak self] in
+ result?.forEach { history in
+ self?.context.managedObjectContext.delete(history)
+ }
+ self?.isSearching.value = true
+ }
+ }
}
diff --git a/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift b/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift
new file mode 100644
index 00000000..7ab18bb0
--- /dev/null
+++ b/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift
@@ -0,0 +1,47 @@
+//
+// SearchBottomLoader.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/6.
+//
+
+import Foundation
+import UIKit
+
+final class SearchBottomLoader: UITableViewCell {
+ let activityIndicatorView: UIActivityIndicatorView = {
+ let activityIndicatorView = UIActivityIndicatorView(style: .medium)
+ activityIndicatorView.tintColor = Asset.Colors.Label.primary.color
+ activityIndicatorView.hidesWhenStopped = true
+ return activityIndicatorView
+ }()
+
+ override func prepareForReuse() {
+ super.prepareForReuse()
+ }
+
+ override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
+ super.init(style: style, reuseIdentifier: reuseIdentifier)
+ _init()
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ _init()
+ }
+
+ func startAnimating() {
+ activityIndicatorView.startAnimating()
+ }
+
+ func stopAnimating() {
+ activityIndicatorView.stopAnimating()
+ }
+
+ func _init() {
+ selectionStyle = .none
+ backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
+ contentView.addSubview(activityIndicatorView)
+ activityIndicatorView.constrainToCenter()
+ }
+}
diff --git a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift
new file mode 100644
index 00000000..5a258d8a
--- /dev/null
+++ b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift
@@ -0,0 +1,151 @@
+//
+// SearchingTableViewCell.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/2.
+//
+
+import CoreData
+import CoreDataStack
+import Foundation
+import MastodonSDK
+import UIKit
+
+final class SearchingTableViewCell: UITableViewCell {
+ 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
+ }()
+
+ override func prepareForReuse() {
+ super.prepareForReuse()
+ _imageView.af.cancelImageRequest()
+ _imageView.image = nil
+ }
+
+ override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
+ super.init(style: style, reuseIdentifier: reuseIdentifier)
+ configure()
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ configure()
+ }
+}
+
+extension SearchingTableViewCell {
+ private func configure() {
+ backgroundColor = .clear
+ selectionStyle = .none
+ contentView.addSubview(_imageView)
+ _imageView.pin(toSize: CGSize(width: 42, height: 42))
+ _imageView.constrain([
+ _imageView.constraint(.leading, toView: contentView, constant: 21),
+ _imageView.constraint(.centerY, toView: contentView)
+ ])
+
+ contentView.addSubview(_titleLabel)
+ _titleLabel.pin(top: 12, left: 75, bottom: nil, right: 0)
+
+ contentView.addSubview(_subTitleLabel)
+ _subTitleLabel.pin(top: 34, left: 75, bottom: nil, right: 0)
+ }
+
+ func config(with account: Mastodon.Entity.Account) {
+ _imageView.af.setImage(
+ withURL: URL(string: account.avatar)!,
+ placeholderImage: UIImage.placeholder(color: .systemFill),
+ imageTransition: .crossDissolve(0.2)
+ )
+ _titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName
+ _subTitleLabel.text = account.acct
+ }
+
+ func config(with account: MastodonUser) {
+ _imageView.af.setImage(
+ withURL: URL(string: account.avatar)!,
+ placeholderImage: UIImage.placeholder(color: .systemFill),
+ imageTransition: .crossDissolve(0.2)
+ )
+ _titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName
+ _subTitleLabel.text = account.acct
+ }
+
+ func config(with tag: Mastodon.Entity.Tag) {
+ let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate)
+ _imageView.image = image
+ _titleLabel.text = "# " + tag.name
+ guard let historys = tag.history else {
+ _subTitleLabel.text = ""
+ return
+ }
+ let recentHistory = historys.prefix(2)
+ let peopleAreTalking = recentHistory.compactMap { Int($0.accounts) }.reduce(0, +)
+ let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking))
+ _subTitleLabel.text = string
+ }
+
+ func config(with tag: Tag) {
+ let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate)
+ _imageView.image = image
+ _titleLabel.text = "# " + tag.name
+ guard let historys = tag.histories?.sorted(by: {
+ $0.createAt.compare($1.createAt) == .orderedAscending
+ }) else {
+ _subTitleLabel.text = ""
+ return
+ }
+ let recentHistory = historys.prefix(2)
+ let peopleAreTalking = recentHistory.compactMap { Int($0.accounts) }.reduce(0, +)
+ let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking))
+ _subTitleLabel.text = string
+ }
+}
+
+#if canImport(SwiftUI) && DEBUG
+import SwiftUI
+
+struct SearchingTableViewCell_Previews: PreviewProvider {
+ static var controls: some View {
+ Group {
+ UIViewPreview {
+ let cell = SearchingTableViewCell()
+ cell.backgroundColor = .white
+ cell._imageView.image = UIImage(systemName: "number.circle.fill")
+ cell._titleLabel.text = "Electronic Frontier Foundation"
+ cell._subTitleLabel.text = "@eff@mastodon.social"
+ 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/View/SearchRecommendCollectionHeader.swift b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift
index df876a63..bc5bd766 100644
--- a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift
+++ b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift
@@ -25,10 +25,10 @@ class SearchRecommendCollectionHeader: UIView {
return label
}()
- let seeAllButton: UIButton = {
- let button = UIButton(type: .custom)
+ let seeAllButton: HighlightDimmableButton = {
+ let button = HighlightDimmableButton(type: .custom)
button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
- button.setTitle(L10n.Scene.Search.Recommend.buttontext, for: .normal)
+ button.setTitle(L10n.Scene.Search.Recommend.buttonText, for: .normal)
return button
}()
diff --git a/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift b/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift
index 8ba7c225..5f430271 100644
--- a/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift
+++ b/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift
@@ -10,7 +10,31 @@ import UIKit
// Make status bar style adptive for child view controller
// SeeAlso: `modalPresentationCapturesStatusBarAppearance`
final class AdaptiveStatusBarStyleNavigationController: UINavigationController {
+ var viewControllersHiddenNavigationBar: [UIViewController.Type]
+
override var childForStatusBarStyle: UIViewController? {
- return visibleViewController
+ visibleViewController
+ }
+
+ override init(rootViewController: UIViewController) {
+ self.viewControllersHiddenNavigationBar = [SearchViewController.self]
+ super.init(rootViewController: rootViewController)
+ self.delegate = self
+ }
+
+ @available(*, unavailable)
+ required init?(coder aDecoder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
+
+extension AdaptiveStatusBarStyleNavigationController: UINavigationControllerDelegate {
+ func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
+ let isContain = self.viewControllersHiddenNavigationBar.contains { type(of: viewController) == $0 }
+ if isContain {
+ self.setNavigationBarHidden(true, animated: animated)
+ } else {
+ self.setNavigationBarHidden(false, animated: animated)
+ }
}
}
diff --git a/Mastodon/Service/APIService/APIService+Recommend.swift b/Mastodon/Service/APIService/APIService+Recommend.swift
index bf6db017..1c58fc57 100644
--- a/Mastodon/Service/APIService/APIService+Recommend.swift
+++ b/Mastodon/Service/APIService/APIService+Recommend.swift
@@ -5,12 +5,14 @@
// Created by sxiaojian on 2021/3/31.
//
+import Combine
import Foundation
import MastodonSDK
-import Combine
+import CoreData
+import CoreDataStack
+import OSLog
extension APIService {
-
func recommendAccount(
domain: String,
query: Mastodon.API.Suggestions.Query?,
@@ -19,12 +21,33 @@ extension APIService {
let authorization = mastodonAuthenticationBox.userAuthorization
return Mastodon.API.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization)
+ .flatMap { response -> AnyPublisher, Error> in
+ let log = OSLog.api
+ return self.backgroundManagedObjectContext.performChanges {
+ response.value.forEach { user in
+ 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.Account]> in
+ switch result {
+ case .success:
+ return response
+ case .failure(let error):
+ throw error
+ }
+ }
+ .eraseToAnyPublisher()
+ }
+ .eraseToAnyPublisher()
}
-
+
func recommendTrends(
domain: String,
query: Mastodon.API.Trends.Query?
) -> AnyPublisher, Error> {
- return Mastodon.API.Trends.get(session: session, domain: domain, query: query)
+ Mastodon.API.Trends.get(session: session, domain: domain, query: query)
}
}
diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift
new file mode 100644
index 00000000..9b431957
--- /dev/null
+++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift
@@ -0,0 +1,66 @@
+//
+// APIService+CoreData+Tag.swift
+// Mastodon
+//
+// Created by sxiaojian on 2021/4/8.
+//
+
+import CoreData
+import CoreDataStack
+import Foundation
+import MastodonSDK
+
+extension APIService.CoreData {
+ static func createOrMergeTag(
+ into managedObjectContext: NSManagedObjectContext,
+ entity: Mastodon.Entity.Tag
+ ) -> (Tag: Tag, isCreated: Bool) {
+ // fetch old mastodon user
+ let oldTag: Tag? = {
+ let request = Tag.sortedFetchRequest
+ request.predicate = Tag.predicate(name: entity.name)
+ request.fetchLimit = 1
+ request.returnsObjectsAsFaults = false
+ do {
+ return try managedObjectContext.fetch(request).first
+ } catch {
+ assertionFailure(error.localizedDescription)
+ return nil
+ }
+ }()
+
+ if let oldTag = oldTag {
+ APIService.CoreData.merge(tag: oldTag, entity: entity, into: managedObjectContext)
+ return (oldTag, false)
+ } else {
+ let histories = entity.history?.prefix(2).compactMap { history -> History in
+ History.insert(into: managedObjectContext, property: History.Property(day: history.day, uses: history.uses, accounts: history.accounts))
+ }
+ let tagInCoreData = Tag.insert(into: managedObjectContext, property: Tag.Property(name: entity.name, url: entity.url, histories: histories))
+ return (tagInCoreData, true)
+ }
+ }
+
+ static func merge(tag: Tag, entity: Mastodon.Entity.Tag, into managedObjectContext: NSManagedObjectContext) {
+ tag.update(url: tag.url)
+ guard let tagHistories = tag.histories else { return }
+ guard let entityHistories = entity.history?.prefix(2) else { return }
+ let entityHistoriesCount = entityHistories.count
+ if entityHistoriesCount == 0 {
+ return
+ }
+ for n in 0 ..< tagHistories.count {
+ if n < entityHistories.count {
+ let entityHistory = entityHistories[n]
+ tag.updateHistory(index: n, day: entityHistory.day, uses: entityHistory.uses, account: entityHistory.accounts)
+ }
+ }
+ if entityHistoriesCount <= tagHistories.count {
+ return
+ }
+ for n in 1 ... (entityHistoriesCount - tagHistories.count) {
+ let entityHistory = entityHistories[entityHistoriesCount - n]
+ tag.appendHistory(history: History.insert(into: managedObjectContext, property: History.Property(day: entityHistory.day, uses: entityHistory.uses, accounts: entityHistory.accounts)))
+ }
+ }
+}
diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift
index 42dfc1e2..be8bb260 100644
--- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift
+++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift
@@ -50,9 +50,6 @@ extension Mastodon.API.Search {
}
extension Mastodon.API.Search {
- public enum SearchType: String, Codable {
- case ccounts, hashtags, statuses
- }
public struct Query: Codable, GetQuery {
public init(q: String,
@@ -65,6 +62,7 @@ extension Mastodon.API.Search {
limit: Int? = nil,
offset: Int? = nil,
following: Bool? = nil) {
+
self.accountID = accountID
self.maxID = maxID
self.minID = minID
@@ -106,3 +104,25 @@ extension Mastodon.API.Search {
}
}
}
+
+public extension Mastodon.API.Search {
+ enum SearchType: String, Codable {
+ case accounts
+ case hashtags
+ case statuses
+ case `default`
+
+ public var rawValue: String {
+ switch self {
+ case .accounts:
+ return "accounts"
+ case .hashtags:
+ return "hashtags"
+ case .statuses:
+ return "statuses"
+ case .default:
+ return ""
+ }
+ }
+ }
+}
diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift
index f1033966..44446d0d 100644
--- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift
+++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift
@@ -8,6 +8,12 @@
import Foundation
extension Mastodon.Entity {
public struct SearchResult: Codable {
+ public init(accounts: [Mastodon.Entity.Account], statuses: [Mastodon.Entity.Status], hashtags: [Mastodon.Entity.Tag]) {
+ self.accounts = accounts
+ self.statuses = statuses
+ self.hashtags = hashtags
+ }
+
public let accounts: [Mastodon.Entity.Account]
public let statuses: [Mastodon.Entity.Status]
public let hashtags: [Mastodon.Entity.Tag]