feat: add search history back

This commit is contained in:
CMK 2021-07-15 20:28:36 +08:00
parent acc24b7ef5
commit 647f87744b
39 changed files with 738 additions and 306 deletions

View File

@ -136,6 +136,7 @@
<relationship name="privateNotes" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PrivateNote" inverseName="to" inverseEntity="PrivateNote"/>
<relationship name="privateNotesTo" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PrivateNote" inverseName="from" inverseEntity="PrivateNote"/>
<relationship name="reblogged" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="rebloggedBy" inverseEntity="Status"/>
<relationship name="searchHistory" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SearchHistory" inverseName="account" inverseEntity="SearchHistory"/>
<relationship name="statuses" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="author" inverseEntity="Status"/>
<relationship name="votePollOptions" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PollOption" inverseName="votedBy" inverseEntity="PollOption"/>
<relationship name="votePolls" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Poll" inverseName="votedBy" inverseEntity="Poll"/>
@ -182,8 +183,9 @@
<attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser"/>
<relationship name="hashtag" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Tag"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="searchHistory" inverseEntity="MastodonUser"/>
<relationship name="hashtag" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Tag" inverseName="searchHistory" inverseEntity="Tag"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="searchHistory" inverseEntity="Status"/>
</entity>
<entity name="Setting" representedClassName=".Setting" syncable="YES">
<attribute name="appearanceRaw" attributeType="String"/>
@ -234,6 +236,7 @@
<relationship name="rebloggedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="reblogged" inverseEntity="MastodonUser"/>
<relationship name="replyFrom" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="replyTo" inverseEntity="Status"/>
<relationship name="replyTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="replyFrom" inverseEntity="Status"/>
<relationship name="searchHistory" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SearchHistory" inverseName="status" inverseEntity="SearchHistory"/>
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="statuses" inverseEntity="Tag"/>
</entity>
<entity name="Subscription" representedClassName=".Subscription" syncable="YES">
@ -266,6 +269,7 @@
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="url" attributeType="String"/>
<relationship name="histories" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="History" inverseName="tag" inverseEntity="History"/>
<relationship name="searchHistory" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SearchHistory" inverseName="hashtag" inverseEntity="SearchHistory"/>
<relationship name="statuses" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="tags" inverseEntity="Status"/>
</entity>
<elements>
@ -277,16 +281,16 @@
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>
<element name="MastodonAuthentication" positionX="0" positionY="0" width="128" height="209"/>
<element name="MastodonNotification" positionX="9" positionY="162" width="128" height="164"/>
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="704"/>
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="719"/>
<element name="Mention" positionX="0" positionY="0" width="128" height="149"/>
<element name="Poll" positionX="0" positionY="0" width="128" height="194"/>
<element name="PollOption" positionX="0" positionY="0" width="128" height="134"/>
<element name="PrivateNote" positionX="0" positionY="0" width="128" height="89"/>
<element name="SearchHistory" positionX="0" positionY="0" width="128" height="104"/>
<element name="SearchHistory" positionX="0" positionY="0" width="128" height="119"/>
<element name="Setting" positionX="72" positionY="162" width="128" height="164"/>
<element name="Status" positionX="0" positionY="0" width="128" height="599"/>
<element name="Status" positionX="0" positionY="0" width="128" height="614"/>
<element name="Subscription" positionX="81" positionY="171" width="128" height="179"/>
<element name="SubscriptionAlerts" positionX="72" positionY="162" width="128" height="14"/>
<element name="Tag" positionX="0" positionY="0" width="128" height="134"/>
<element name="Tag" positionX="0" positionY="0" width="128" height="149"/>
</elements>
</model>

View File

@ -43,6 +43,7 @@ final public class MastodonUser: NSManagedObject {
// one-to-one relationship
@NSManaged public private(set) var pinnedStatus: Status?
@NSManaged public private(set) var mastodonAuthentication: MastodonAuthentication?
@NSManaged public private(set) var searchHistory: SearchHistory?
// one-to-many relationship
@NSManaged public private(set) var statuses: Set<Status>?

View File

@ -13,9 +13,11 @@ public final class SearchHistory: NSManagedObject {
@NSManaged public private(set) var identifier: ID
@NSManaged public private(set) var createAt: Date
@NSManaged public private(set) var updatedAt: Date
// one-to-one relationship
@NSManaged public private(set) var account: MastodonUser?
@NSManaged public private(set) var hashtag: Tag?
@NSManaged public private(set) var status: Status?
}
@ -51,6 +53,16 @@ extension SearchHistory {
searchHistory.hashtag = hashtag
return searchHistory
}
@discardableResult
public static func insert(
into context: NSManagedObjectContext,
status: Status
) -> SearchHistory {
let searchHistory: SearchHistory = context.insertObject()
searchHistory.status = status
return searchHistory
}
}
public extension SearchHistory {

View File

@ -38,20 +38,21 @@ public final class Status: NSManagedObject {
@NSManaged public private(set) var language: String? // (ISO 639 Part 1 two-letter language code)
@NSManaged public private(set) var text: String?
// many-to-one relastionship
// many-to-one relationship
@NSManaged public private(set) var author: MastodonUser
@NSManaged public private(set) var reblog: Status?
@NSManaged public private(set) var replyTo: Status?
// many-to-many relastionship
// many-to-many relationship
@NSManaged public private(set) var favouritedBy: Set<MastodonUser>?
@NSManaged public private(set) var rebloggedBy: Set<MastodonUser>?
@NSManaged public private(set) var mutedBy: Set<MastodonUser>?
@NSManaged public private(set) var bookmarkedBy: Set<MastodonUser>?
// one-to-one relastionship
// one-to-one relationship
@NSManaged public private(set) var pinnedBy: MastodonUser?
@NSManaged public private(set) var poll: Poll?
@NSManaged public private(set) var searchHistory: SearchHistory?
// one-to-many relationship
@NSManaged public private(set) var reblogFrom: Set<Status>?

View File

@ -17,6 +17,9 @@ public final class Tag: NSManagedObject {
@NSManaged public private(set) var name: String
@NSManaged public private(set) var url: String
// one-to-one relationship
@NSManaged public private(set) var searchHistory: SearchHistory?
// many-to-many relationship
@NSManaged public private(set) var statuses: Set<Status>?

View File

@ -274,6 +274,10 @@
DB4F0968269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */; };
DB4F096A269EDAD200D62E92 /* SearchResultViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F0969269EDAD200D62E92 /* SearchResultViewModel+State.swift */; };
DB4F096C269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F096B269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift */; };
DB4F097526A037F500D62E92 /* SearchHistoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F097426A037F500D62E92 /* SearchHistoryViewModel.swift */; };
DB4F097B26A039FF00D62E92 /* SearchHistorySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F097A26A039FF00D62E92 /* SearchHistorySection.swift */; };
DB4F097D26A03A5B00D62E92 /* SearchHistoryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F097C26A03A5B00D62E92 /* SearchHistoryItem.swift */; };
DB4F097F26A03DA600D62E92 /* SearchHistoryFetchedResultController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4F097E26A03DA600D62E92 /* SearchHistoryFetchedResultController.swift */; };
DB4FFC2B269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4FFC29269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift */; };
DB4FFC2C269EC39600D62E92 /* SearchTransitionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4FFC2A269EC39600D62E92 /* SearchTransitionController.swift */; };
DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */; };
@ -921,6 +925,10 @@
DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryTableHeaderView.swift; sourceTree = "<group>"; };
DB4F0969269EDAD200D62E92 /* SearchResultViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewModel+State.swift"; sourceTree = "<group>"; };
DB4F096B269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchResultViewController+StatusProvider.swift"; sourceTree = "<group>"; };
DB4F097426A037F500D62E92 /* SearchHistoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryViewModel.swift; sourceTree = "<group>"; };
DB4F097A26A039FF00D62E92 /* SearchHistorySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistorySection.swift; sourceTree = "<group>"; };
DB4F097C26A03A5B00D62E92 /* SearchHistoryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryItem.swift; sourceTree = "<group>"; };
DB4F097E26A03DA600D62E92 /* SearchHistoryFetchedResultController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryFetchedResultController.swift; sourceTree = "<group>"; };
DB4FFC29269EC39600D62E92 /* SearchToSearchDetailViewControllerAnimatedTransitioning.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchToSearchDetailViewControllerAnimatedTransitioning.swift; sourceTree = "<group>"; };
DB4FFC2A269EC39600D62E92 /* SearchTransitionController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchTransitionController.swift; sourceTree = "<group>"; };
DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = "<group>"; };
@ -1509,6 +1517,7 @@
DB297B1A2679FAE200704C90 /* PlaceholderImageCacheService.swift */,
DBAEDE5B267A058D00D25FF5 /* BlurhashImageCacheService.swift */,
DBAEDE60267B342D00D25FF5 /* StatusContentCacheService.swift */,
DB564BD2269F3B35001E39A7 /* StatusFilterService.swift */,
);
path = Service;
sourceTree = "<group>";
@ -1570,24 +1579,13 @@
2D76319D25C151F600929FB9 /* Section */ = {
isa = PBXGroup;
children = (
2D76319E25C1521200929FB9 /* StatusSection.swift */,
DB4481C525EE2ADA00BEFB67 /* PollSection.swift */,
DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */,
DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */,
2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */,
2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */,
DB4F097926A039C400D62E92 /* Status */,
DB4F097826A039B400D62E92 /* Onboarding */,
DB4F097726A039A200D62E92 /* Search */,
DB4F097626A0398000D62E92 /* Compose */,
2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */,
2D35237926256D920031AF25 /* NotificationSection.swift */,
2D198648261C0B8500F0B013 /* SearchResultSection.swift */,
DB66729525F9F91600D60309 /* ComposeStatusSection.swift */,
DB36679E268ABAF20027D07F /* ComposeStatusAttachmentSection.swift */,
DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */,
DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */,
DB6D9F7C26358ED4008423CD /* SettingsSection.swift */,
5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */,
DBBF1DC82652538500E5B703 /* AutoCompleteSection.swift */,
DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */,
DB564BD2269F3B35001E39A7 /* StatusFilterService.swift */,
);
path = Section;
sourceTree = "<group>";
@ -1641,6 +1639,7 @@
children = (
2D7631B225C159F700929FB9 /* Item.swift */,
2D198642261BF09500F0B013 /* SearchResultItem.swift */,
DB4F097C26A03A5B00D62E92 /* SearchHistoryItem.swift */,
2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */,
2D7867182625B77500211898 /* NotificationItem.swift */,
DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */,
@ -2010,7 +2009,6 @@
DB4F0964269ED06700D62E92 /* SearchResult */ = {
isa = PBXGroup;
children = (
2DFAD5212616F8E300F9EE7C /* TableViewCell */,
DB4F0962269ED06300D62E92 /* SearchResultViewController.swift */,
DB4F096B269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift */,
DB4F0965269ED52200D62E92 /* SearchResultViewModel.swift */,
@ -2019,6 +2017,57 @@
path = SearchResult;
sourceTree = "<group>";
};
DB4F097626A0398000D62E92 /* Compose */ = {
isa = PBXGroup;
children = (
DB66729525F9F91600D60309 /* ComposeStatusSection.swift */,
DB36679E268ABAF20027D07F /* ComposeStatusAttachmentSection.swift */,
DB3667A5268AE2620027D07F /* ComposeStatusPollSection.swift */,
DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */,
DBBF1DC82652538500E5B703 /* AutoCompleteSection.swift */,
);
path = Compose;
sourceTree = "<group>";
};
DB4F097726A039A200D62E92 /* Search */ = {
isa = PBXGroup;
children = (
2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */,
2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */,
2D198648261C0B8500F0B013 /* SearchResultSection.swift */,
DB4F097A26A039FF00D62E92 /* SearchHistorySection.swift */,
);
path = Search;
sourceTree = "<group>";
};
DB4F097826A039B400D62E92 /* Onboarding */ = {
isa = PBXGroup;
children = (
DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */,
DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */,
);
path = Onboarding;
sourceTree = "<group>";
};
DB4F097926A039C400D62E92 /* Status */ = {
isa = PBXGroup;
children = (
2D76319E25C1521200929FB9 /* StatusSection.swift */,
DB4481C525EE2ADA00BEFB67 /* PollSection.swift */,
2D35237926256D920031AF25 /* NotificationSection.swift */,
5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */,
);
path = Status;
sourceTree = "<group>";
};
DB4F098026A0475500D62E92 /* View */ = {
isa = PBXGroup;
children = (
DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */,
);
path = View;
sourceTree = "<group>";
};
DB4FFC2D269EC39C00D62E92 /* Search */ = {
isa = PBXGroup;
children = (
@ -2428,8 +2477,6 @@
children = (
DBF1D253269DB02C00C1C08A /* Search */,
DBF1D24F269DAF6100C1C08A /* SearchDetail */,
DB4F0964269ED06700D62E92 /* SearchResult */,
DBF1D252269DB01700C1C08A /* SearchHistory */,
);
path = Search;
sourceTree = "<group>";
@ -2656,6 +2703,7 @@
DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */,
DBA088DE26958164003EB4B2 /* UserFetchedResultsController.swift */,
DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */,
DB4F097E26A03DA600D62E92 /* SearchHistoryFetchedResultController.swift */,
);
path = FetchedResultsController;
sourceTree = "<group>";
@ -2696,6 +2744,9 @@
DBF1D24F269DAF6100C1C08A /* SearchDetail */ = {
isa = PBXGroup;
children = (
2DFAD5212616F8E300F9EE7C /* TableViewCell */,
DB4F0964269ED06700D62E92 /* SearchResult */,
DBF1D252269DB01700C1C08A /* SearchHistory */,
DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */,
DBF1D256269DBAC600C1C08A /* SearchDetailViewModel.swift */,
);
@ -2705,8 +2756,9 @@
DBF1D252269DB01700C1C08A /* SearchHistory */ = {
isa = PBXGroup;
children = (
DB4F098026A0475500D62E92 /* View */,
DBF1D250269DB01200C1C08A /* SearchHistoryViewController.swift */,
DB4F0967269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift */,
DB4F097426A037F500D62E92 /* SearchHistoryViewModel.swift */,
);
path = SearchHistory;
sourceTree = "<group>";
@ -3388,6 +3440,7 @@
2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */,
0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */,
DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */,
DB4F097F26A03DA600D62E92 /* SearchHistoryFetchedResultController.swift in Sources */,
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */,
DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */,
DBE3CE0D261D767100430CC6 /* FavoriteViewController+Provider.swift in Sources */,
@ -3528,6 +3581,7 @@
DBA94438265CBD4D00C537E1 /* ProfileHeaderViewModel+Diffable.swift in Sources */,
0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */,
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */,
DB4F097526A037F500D62E92 /* SearchHistoryViewModel.swift in Sources */,
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
DB6180F826391D660018D199 /* MediaPreviewingViewController.swift in Sources */,
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
@ -3555,6 +3609,7 @@
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */,
DBD376AC2692ECDB007FEC24 /* ThemePreference.swift in Sources */,
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */,
DB4F097D26A03A5B00D62E92 /* SearchHistoryItem.swift in Sources */,
DB68046C2636DC9E00430867 /* MastodonNotification.swift in Sources */,
DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */,
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */,
@ -3624,6 +3679,7 @@
DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */,
DB9A489026035963008B817C /* APIService+Media.swift in Sources */,
2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */,
DB4F097B26A039FF00D62E92 /* SearchHistorySection.swift in Sources */,
DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */,
DBCBCC0B2680B03F000F5B51 /* AsyncHomeTimelineViewModel+LoadOldestState.swift in Sources */,
2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */,

View File

@ -0,0 +1,53 @@
//
// SearchHistoryFetchedResultController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-15.
//
import os.log
import UIKit
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
final class SearchHistoryFetchedResultController: NSObject {
var disposeBag = Set<AnyCancellable>()
let fetchedResultsController: NSFetchedResultsController<SearchHistory>
// output
let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([])
init(managedObjectContext: NSManagedObjectContext) {
self.fetchedResultsController = {
let fetchRequest = SearchHistory.sortedFetchRequest
fetchRequest.returnsObjectsAsFaults = false
fetchRequest.fetchBatchSize = 20
let controller = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: managedObjectContext,
sectionNameKeyPath: nil,
cacheName: nil
)
return controller
}()
super.init()
fetchedResultsController.delegate = self
}
}
// MARK: - NSFetchedResultsControllerDelegate
extension SearchHistoryFetchedResultController: NSFetchedResultsControllerDelegate {
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
let objects = fetchedResultsController.fetchedObjects ?? []
self.objectIDs.value = objects.map { $0.objectID }
}
}

View File

@ -0,0 +1,41 @@
//
// SearchHistoryItem.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-15.
//
import Foundation
import CoreData
enum SearchHistoryItem {
case account(objectID: NSManagedObjectID)
case hashtag(objectID: NSManagedObjectID)
case status(objectID: NSManagedObjectID, attribute: Item.StatusAttribute)
}
extension SearchHistoryItem: Hashable {
static func == (lhs: SearchHistoryItem, rhs: SearchHistoryItem) -> Bool {
switch (lhs, rhs) {
case (.account(let objectIDLeft), account(let objectIDRight)):
return objectIDLeft == objectIDRight
case (.hashtag(let objectIDLeft), hashtag(let objectIDRight)):
return objectIDLeft == objectIDRight
case (.status(let objectIDLeft, _), status(let objectIDRight, _)):
return objectIDLeft == objectIDRight
default:
return false
}
}
func hash(into hasher: inout Hasher) {
switch self {
case .account(let objectID):
hasher.combine(objectID)
case .hashtag(let objectID):
hasher.combine(objectID)
case .status(let objectID, _):
hasher.combine(objectID)
}
}
}

View File

@ -12,11 +12,7 @@ import MastodonSDK
enum SearchResultItem {
case hashtag(tag: Mastodon.Entity.Tag)
case account(account: Mastodon.Entity.Account)
case accountObjectID(accountObjectID: NSManagedObjectID)
case hashtagObjectID(hashtagObjectID: NSManagedObjectID)
case status(statusObjectID: NSManagedObjectID, attribute: Item.StatusAttribute)
case bottomLoader(attribute: BottomLoaderAttribute)
}
@ -47,10 +43,6 @@ extension SearchResultItem: Equatable {
return tagLeft == tagRight
case (.account(let accountLeft), .account(let accountRight)):
return accountLeft == accountRight
case (.accountObjectID(let idLeft), .accountObjectID(let idRight)):
return idLeft == idRight
case (.hashtagObjectID(let idLeft), .hashtagObjectID(let idRight)):
return idLeft == idRight
case (.status(let idLeft, _), .status(let idRight, _)):
return idLeft == idRight
case (.bottomLoader(let attributeLeft), .bottomLoader(let attributeRight)):
@ -70,10 +62,6 @@ extension SearchResultItem: Hashable {
case .hashtag(let tag):
hasher.combine(String(describing: SearchResultItem.hashtag.self))
hasher.combine(tag.name)
case .accountObjectID(let id):
hasher.combine(id)
case .hashtagObjectID(let id):
hasher.combine(id)
case .status(let id, _):
hasher.combine(id)
case .bottomLoader(let attribute):
@ -99,8 +87,6 @@ extension SearchResultItem {
return .status(objectID: objectID)
case .hashtag,
.account,
.accountObjectID,
.hashtagObjectID,
.bottomLoader:
return nil
}

View File

@ -0,0 +1,56 @@
//
// SearchHistorySection.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-15.
//
import UIKit
import CoreDataStack
enum SearchHistorySection: Hashable {
case main
}
extension SearchHistorySection {
static func tableViewDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency
) -> UITableViewDiffableDataSource<SearchHistorySection, SearchHistoryItem> {
UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
switch item {
case .account(let objectID):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchResultTableViewCell.self), for: indexPath) as! SearchResultTableViewCell
if let user = try? dependency.context.managedObjectContext.existingObject(with: objectID) as? MastodonUser {
cell.config(with: user)
}
return cell
case .hashtag(let objectID):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchResultTableViewCell.self), for: indexPath) as! SearchResultTableViewCell
if let hashtag = try? dependency.context.managedObjectContext.existingObject(with: objectID) as? Tag {
cell.config(with: hashtag)
}
return cell
case .status:
return UITableViewCell()
// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
// if let status = try? dependency.context.managedObjectContext.existingObject(with: statusObjectID) as? Status {
// let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value
// let requestUserID = activeMastodonAuthenticationBox?.userID ?? ""
// StatusSection.configure(
// cell: cell,
// tableView: tableView,
// timelineContext: .search,
// dependency: dependency,
// readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
// status: status,
// requestUserID: requestUserID,
// statusItemAttribute: attribute
// )
// }
// cell.delegate = statusTableViewCellDelegate
// return cell
} // end switch
} // end UITableViewDiffableDataSource
} // end func
}

View File

@ -25,13 +25,7 @@ final class SearchViewModel: NSObject {
let viewDidAppeared = PassthroughSubject<Void, Never>()
// output
let searchText = CurrentValueSubject<String, Never>("")
let searchScope = CurrentValueSubject<Mastodon.API.V2.Search.SearchType, Never>(Mastodon.API.V2.Search.SearchType.default)
let isSearching = CurrentValueSubject<Bool, Never>(false)
let searchResult = CurrentValueSubject<Mastodon.Entity.SearchResult?, Never>(nil)
// var recommendHashTags = [Mastodon.Entity.Tag]()
var recommendAccounts = [NSManagedObjectID]()
var recommendAccountsFallback = PassthroughSubject<Void, Never>()
@ -39,85 +33,11 @@ final class SearchViewModel: NSObject {
var hashtagDiffableDataSource: UICollectionViewDiffableDataSource<RecommendHashTagSection, Mastodon.Entity.Tag>?
var accountDiffableDataSource: UICollectionViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID>?
let statusFetchedResultsController: StatusFetchedResultsController
init(context: AppContext, coordinator: SceneCoordinator) {
self.coordinator = coordinator
self.context = context
self.statusFetchedResultsController = StatusFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: nil,
additionalTweetPredicate: nil
)
super.init()
// 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
self.statusFetchedResultsController.domain.value = activeMastodonAuthentication.domain
}
.store(in: &disposeBag)
Publishers.CombineLatest(
searchText
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(),
searchScope
)
.filter { text, _ in
!text.isEmpty
}
.compactMap { (text, scope) -> AnyPublisher<Result<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error>, Never>? in
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return nil }
let query = Mastodon.API.V2.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
)
// .retry(3) // iOS 14.0 SDK may not works here. needs testing before add this
.map { response in Result<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> { response } }
.catch { error in Just(Result<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> { throw error }) }
.eraseToAnyPublisher()
}
.switchToLatest()
.sink { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let response):
guard self.isSearching.value else { return }
self.searchResult.value = response.value
case .failure(let error):
break
}
}
.store(in: &disposeBag)
isSearching
.sink { [weak self] isSearching in
if !isSearching {
self?.searchResult.value = nil
self?.searchText.value = ""
}
}
.store(in: &disposeBag)
Publishers.CombineLatest(
context.authenticationService.activeMastodonAuthenticationBox,
viewDidAppeared
@ -220,126 +140,5 @@ final class SearchViewModel: NSObject {
snapshot.appendItems(self.recommendAccounts, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
}
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
}
}
}

View File

@ -23,6 +23,7 @@ final class SearchDetailViewController: PageboyViewController, NeedsDependency {
var viewModel: SearchDetailViewModel!
var viewControllers: [SearchResultViewController]!
let navigationBarVisualEffectBackgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
let navigationBarBackgroundView = UIView()
let navigationBar: UINavigationBar = {
let navigationItem = UINavigationItem()
@ -32,7 +33,9 @@ final class SearchDetailViewController: PageboyViewController, NeedsDependency {
navigationItem.compactAppearance = barAppearance
navigationItem.scrollEdgeAppearance = barAppearance
let navigationBar = UINavigationBar()
let navigationBar = UINavigationBar(
frame: CGRect(x: 0, y: 0, width: 300, height: 100)
)
navigationBar.setItems([navigationItem], animated: false)
return navigationBar
}()
@ -40,9 +43,18 @@ final class SearchDetailViewController: PageboyViewController, NeedsDependency {
let searchBar = UISearchBar()
searchBar.placeholder = L10n.Scene.Search.SearchBar.placeholder
searchBar.scopeButtonTitles = SearchDetailViewModel.SearchScope.allCases.map { $0.segmentedControlTitle }
searchBar.sizeToFit()
searchBar.scopeBarBackgroundImage = UIImage()
return searchBar
}()
private(set) lazy var searchHistoryViewController: SearchHistoryViewController = {
let searchHistoryViewController = SearchHistoryViewController()
searchHistoryViewController.context = context
searchHistoryViewController.coordinator = coordinator
searchHistoryViewController.viewModel = SearchHistoryViewModel(context: context)
return searchHistoryViewController
}()
}
extension SearchDetailViewController {
@ -82,6 +94,26 @@ extension SearchDetailViewController {
navigationBarBackgroundView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor),
])
navigationBarVisualEffectBackgroundView.translatesAutoresizingMaskIntoConstraints = false
view.insertSubview(navigationBarVisualEffectBackgroundView, belowSubview: navigationBarBackgroundView)
NSLayoutConstraint.activate([
navigationBarVisualEffectBackgroundView.topAnchor.constraint(equalTo: navigationBarBackgroundView.topAnchor),
navigationBarVisualEffectBackgroundView.leadingAnchor.constraint(equalTo: navigationBarBackgroundView.leadingAnchor),
navigationBarVisualEffectBackgroundView.trailingAnchor.constraint(equalTo: navigationBarBackgroundView.trailingAnchor),
navigationBarVisualEffectBackgroundView.bottomAnchor.constraint(equalTo: navigationBarBackgroundView.bottomAnchor),
])
addChild(searchHistoryViewController)
searchHistoryViewController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(searchHistoryViewController.view)
searchHistoryViewController.didMove(toParent: self)
NSLayoutConstraint.activate([
searchHistoryViewController.view.topAnchor.constraint(equalTo: navigationBarBackgroundView.bottomAnchor),
searchHistoryViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
searchHistoryViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
searchHistoryViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
transition = Transition(style: .fade, duration: 0.1)
isScrollEnabled = false
@ -168,12 +200,25 @@ extension SearchDetailViewController {
searchResultViewController.viewModel.stateMachine.enter(SearchResultViewModel.State.Loading.self)
}
.store(in: &disposeBag)
// bind search history display
viewModel.searchText
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] searchText in
guard let self = self else { return }
self.searchHistoryViewController.view.isHidden = !searchText.isEmpty
}
.store(in: &disposeBag)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(true, animated: animated)
searchBar.setShowsScope(true, animated: false)
searchBar.setNeedsLayout()
searchBar.layoutIfNeeded()
}
override func viewWillDisappear(_ animated: Bool) {
@ -193,11 +238,7 @@ extension SearchDetailViewController {
extension SearchDetailViewController {
private func setupSearchBar() {
searchBar.setShowsScope(true, animated: false)
searchBar.sizeToFit()
navigationBar.topItem?.titleView = searchBar
navigationBar.sizeToFit()
searchBar.delegate = self
}
@ -222,7 +263,7 @@ extension SearchDetailViewController: UISearchBarDelegate {
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
navigationController?.popViewController(animated: true)
navigationController?.popViewController(animated: false)
}
}
@ -231,7 +272,7 @@ extension SearchDetailViewController: UISearchBarDelegate {
extension SearchDetailViewController: PageboyViewControllerDataSource {
func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int {
return 4
return viewControllers.count
}
func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? {

View File

@ -0,0 +1,128 @@
//
// SearchHistoryViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-13.
//
import UIKit
import Combine
import CoreDataStack
final class SearchHistoryViewController: UIViewController, NeedsDependency {
var disposeBag = Set<AnyCancellable>()
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var viewModel: SearchHistoryViewModel!
let searchHistoryTableHeaderView = SearchHistoryTableHeaderView()
let tableView: UITableView = {
let tableView = UITableView()
tableView.register(SearchResultTableViewCell.self, forCellReuseIdentifier: String(describing: SearchResultTableViewCell.self))
// tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
tableView.separatorStyle = .none
tableView.tableFooterView = UIView()
tableView.backgroundColor = .clear
return tableView
}()
}
extension SearchHistoryViewController {
override func viewDidLoad() {
super.viewDidLoad()
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
ThemeService.shared.currentTheme
.receive(on: RunLoop.main)
.sink { [weak self] theme in
guard let self = self else { return }
self.setupBackgroundColor(theme: theme)
}
.store(in: &disposeBag)
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
tableView.delegate = self
viewModel.setupDiffableDataSource(
tableView: tableView,
dependency: self
)
searchHistoryTableHeaderView.delegate = self
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
tableView.deselectRow(with: transitionCoordinator, animated: animated)
}
}
extension SearchHistoryViewController {
private func setupBackgroundColor(theme: Theme) {
view.backgroundColor = theme.systemGroupedBackgroundColor
}
}
// MARK: - UITableViewDelegate
extension SearchHistoryViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
switch section {
case 0:
return searchHistoryTableHeaderView
default:
return UIView()
}
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
switch section {
case 0:
return UITableView.automaticDimension
default:
return .leastNonzeroMagnitude
}
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
viewModel.persistSearchHistory(for: item)
switch item {
case .account(let objectID):
guard let user = try? viewModel.searchHistoryFetchedResultController.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? MastodonUser else { return }
let profileViewModel = CachedProfileViewModel(context: context, mastodonUser: user)
coordinator.present(scene: .profile(viewModel: profileViewModel), from: self, transition: .show)
case .hashtag(let objectID):
guard let hashtag = try? viewModel.searchHistoryFetchedResultController.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? Tag else { return }
let hashtagViewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag.name)
coordinator.present(scene: .hashtagTimeline(viewModel: hashtagViewModel), from: self, transition: .show)
case .status(let objectID, _):
guard let status = try? viewModel.searchHistoryFetchedResultController.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? Status else { return }
let threadViewModel = CachedThreadViewModel(context: context, status: status)
coordinator.present(scene: .thread(viewModel: threadViewModel), from: self, transition: .show)
}
}
}
// MARK: - SearchHistoryTableHeaderViewDelegate
extension SearchHistoryViewController: SearchHistoryTableHeaderViewDelegate {
func searchHistoryTableHeaderView(_ searchHistoryTableHeaderView: SearchHistoryTableHeaderView, clearSearchHistoryButtonDidPressed button: UIButton) {
viewModel.clearSearchHistory()
}
}

View File

@ -0,0 +1,132 @@
//
// SearchHistoryViewModel.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-15.
//
import UIKit
import Combine
import CoreDataStack
import CommonOSLog
final class SearchHistoryViewModel {
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
let searchHistoryFetchedResultController: SearchHistoryFetchedResultController
// output
var diffableDataSource: UITableViewDiffableDataSource<SearchHistorySection, SearchHistoryItem>!
init(context: AppContext) {
self.context = context
self.searchHistoryFetchedResultController = SearchHistoryFetchedResultController(managedObjectContext: context.managedObjectContext)
// may block main queue by large dataset
searchHistoryFetchedResultController.objectIDs
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] objectIDs in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
let managedObjectContext = self.searchHistoryFetchedResultController.fetchedResultsController.managedObjectContext
var items: [SearchHistoryItem] = []
for objectID in objectIDs {
guard let searchHistory = try? managedObjectContext.existingObject(with: objectID) as? SearchHistory else { continue }
if let account = searchHistory.account {
items.append(.account(objectID: account.objectID))
} else if let hashtag = searchHistory.hashtag {
items.append(.hashtag(objectID: hashtag.objectID))
} else {
// TODO: status
}
}
var snapshot = NSDiffableDataSourceSnapshot<SearchHistorySection, SearchHistoryItem>()
snapshot.appendSections([.main])
snapshot.appendItems(items, toSection: .main)
diffableDataSource.apply(snapshot, animatingDifferences: false)
}
.store(in: &disposeBag)
try? searchHistoryFetchedResultController.fetchedResultsController.performFetch()
}
}
extension SearchHistoryViewModel {
func setupDiffableDataSource(
tableView: UITableView,
dependency: NeedsDependency
) {
diffableDataSource = SearchHistorySection.tableViewDiffableDataSource(
for: tableView,
dependency: dependency
)
var snapshot = NSDiffableDataSourceSnapshot<SearchHistorySection, SearchHistoryItem>()
snapshot.appendSections([.main])
diffableDataSource.apply(snapshot, animatingDifferences: false)
}
}
extension SearchHistoryViewModel {
func persistSearchHistory(for item: SearchHistoryItem) {
switch item {
case .account(let objectID):
let managedObjectContext = context.backgroundManagedObjectContext
managedObjectContext.performChanges {
guard let user = try? managedObjectContext.existingObject(with: objectID) as? MastodonUser else { return }
if let searchHistory = user.searchHistory {
searchHistory.update(updatedAt: Date())
} else {
SearchHistory.insert(into: managedObjectContext, account: user)
}
}
.sink { result in
// do nothing
}
.store(in: &context.disposeBag)
case .hashtag(let objectID):
let managedObjectContext = context.backgroundManagedObjectContext
managedObjectContext.performChanges {
guard let hashtag = try? managedObjectContext.existingObject(with: objectID) as? Tag else { return }
if let searchHistory = hashtag.searchHistory {
searchHistory.update(updatedAt: Date())
} else {
SearchHistory.insert(into: managedObjectContext, hashtag: hashtag)
}
}
.sink { result in
// do nothing
}
.store(in: &context.disposeBag)
case .status:
// FIXME:
break
}
}
func clearSearchHistory() {
let managedObjectContext = context.backgroundManagedObjectContext
managedObjectContext.performChanges {
let request = SearchHistory.sortedFetchRequest
let searchHistories = managedObjectContext.safeFetch(request)
for searchHistory in searchHistories {
managedObjectContext.delete(searchHistory)
}
}
.sink { result in
// do nothing
}
.store(in: &context.disposeBag)
}
}

View File

@ -0,0 +1,96 @@
//
// SearchHistoryTableHeaderView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-14.
//
import os.log
import UIKit
import Combine
protocol SearchHistoryTableHeaderViewDelegate: AnyObject {
func searchHistoryTableHeaderView(_ searchHistoryTableHeaderView: SearchHistoryTableHeaderView, clearSearchHistoryButtonDidPressed button: UIButton)
}
final class SearchHistoryTableHeaderView: UIView {
let logger = Logger(subsystem: "SearchHistory", category: "UI")
weak var delegate: SearchHistoryTableHeaderViewDelegate?
var disposeBag = Set<AnyCancellable>()
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.expandEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10)
button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
button.setTitle(L10n.Scene.Search.Searching.clear, for: .normal)
return button
}()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension SearchHistoryTableHeaderView {
private func _init() {
preservesSuperviewLayoutMargins = true
recentSearchesLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(recentSearchesLabel)
NSLayoutConstraint.activate([
recentSearchesLabel.topAnchor.constraint(equalTo: topAnchor, constant: 16),
recentSearchesLabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
bottomAnchor.constraint(equalTo: recentSearchesLabel.bottomAnchor, constant: 16),
])
clearSearchHistoryButton.translatesAutoresizingMaskIntoConstraints = false
addSubview(clearSearchHistoryButton)
NSLayoutConstraint.activate([
clearSearchHistoryButton.centerYAnchor.constraint(equalTo: recentSearchesLabel.centerYAnchor),
clearSearchHistoryButton.leadingAnchor.constraint(equalTo: recentSearchesLabel.trailingAnchor),
clearSearchHistoryButton.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
])
clearSearchHistoryButton.setContentHuggingPriority(.defaultHigh + 10, for: .horizontal)
clearSearchHistoryButton.addTarget(self, action: #selector(SearchHistoryTableHeaderView.clearSearchHistoryButtonDidPressed(_:)), for: .touchUpInside)
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
ThemeService.shared.currentTheme
.receive(on: RunLoop.main)
.sink { [weak self] theme in
guard let self = self else { return }
self.setupBackgroundColor(theme: theme)
}
.store(in: &disposeBag)
}
}
extension SearchHistoryTableHeaderView {
@objc private func clearSearchHistoryButtonDidPressed(_ sender: UIButton) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
delegate?.searchHistoryTableHeaderView(self, clearSearchHistoryButtonDidPressed: sender)
}
}
extension SearchHistoryTableHeaderView {
private func setupBackgroundColor(theme: Theme) {
backgroundColor = theme.systemGroupedBackgroundColor
}
}

View File

@ -195,7 +195,10 @@ extension SearchResultViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let item = diffableDataSource.itemIdentifier(for: indexPath)
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
viewModel.persistSearchHistory(for: item)
switch item {
case .account(let account):
let profileViewModel = RemoteProfileViewModel(context: context, userID: account.id)
@ -207,8 +210,6 @@ extension SearchResultViewController: UITableViewDelegate {
aspectTableView(tableView, didSelectRowAt: indexPath)
case .bottomLoader:
break
default:
assertionFailure()
}
}

View File

@ -10,6 +10,7 @@ import Combine
import CoreData
import CoreDataStack
import GameplayKit
import CommonOSLog
final class SearchResultViewModel {
@ -137,3 +138,59 @@ extension SearchResultViewModel {
diffableDataSource.apply(snapshot, animatingDifferences: false)
}
}
extension SearchResultViewModel {
func persistSearchHistory(for item: SearchResultItem) {
guard let box = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
let domain = box.domain
switch item {
case .account(let account):
let managedObjectContext = context.backgroundManagedObjectContext
managedObjectContext.performChanges {
let (user, _) = APIService.CoreData.createOrMergeMastodonUser(
into: managedObjectContext,
for: nil,
in: domain,
entity: account,
userCache: nil,
networkDate: Date(),
log: OSLog.api
)
if let searchHistory = user.searchHistory {
searchHistory.update(updatedAt: Date())
} else {
SearchHistory.insert(into: managedObjectContext, account: user)
}
}
.sink { result in
// do nothing
}
.store(in: &context.disposeBag)
case .hashtag(let hashtag):
let managedObjectContext = context.backgroundManagedObjectContext
managedObjectContext.performChanges {
let (hashtag, _) = APIService.CoreData.createOrMergeTag(
into: managedObjectContext,
entity: hashtag
)
if let searchHistory = hashtag.searchHistory {
searchHistory.update(updatedAt: Date())
} else {
SearchHistory.insert(into: managedObjectContext, hashtag: hashtag)
}
}
.sink { result in
// do nothing
}
.store(in: &context.disposeBag)
case .status:
// FIXME:
break
case .bottomLoader:
break
}
}
}

View File

@ -68,14 +68,14 @@ extension SearchResultTableViewCell {
containerStackView.axis = .horizontal
containerStackView.distribution = .fill
containerStackView.spacing = 12
containerStackView.layoutMargins = UIEdgeInsets(top: 12, left: 21, bottom: 12, right: 12)
containerStackView.layoutMargins = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0)
containerStackView.isLayoutMarginsRelativeArrangement = true
containerStackView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(containerStackView)
NSLayoutConstraint.activate([
containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
containerStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])

View File

@ -1,29 +0,0 @@
//
// SearchHistoryTableHeaderView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-14.
//
import Foundation
// lazy var searchHeader: UIView = {
// let view = UIView()
// view.frame = CGRect(origin: .zero, size: CGSize(width: searchingTableView.frame.width, height: 56))
// return view
// }()
//
// let recentSearchesLabel: UILabel = {
// let label = UILabel()
// label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
// label.textColor = Asset.Colors.Label.primary.color
// label.text = L10n.Scene.Search.Searching.recentSearch
// return label
// }()
//
// let clearSearchHistoryButton: HighlightDimmableButton = {
// let button = HighlightDimmableButton(type: .custom)
// button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal)
// button.setTitle(L10n.Scene.Search.Searching.clear, for: .normal)
// return button
// }()

View File

@ -1,17 +0,0 @@
//
// SearchHistoryViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-13.
//
import UIKit
final class SearchHistoryViewController: UIViewController, NeedsDependency {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
}
extension SearchHistoryViewController {
}

View File

@ -15,7 +15,7 @@ final class SearchToSearchDetailViewControllerAnimatedTransitioning: ViewControl
override init(operation: UINavigationController.Operation) {
super.init(operation: operation)
self.transitionDuration = 0.01
self.transitionDuration = 0.2
}
deinit {
@ -37,7 +37,7 @@ extension SearchToSearchDetailViewControllerAnimatedTransitioning {
}
}
private func pushTransition(using transitionContext: UIViewControllerContextTransitioning, curve: UIView.AnimationCurve = .easeInOut) -> UIViewPropertyAnimator {
private func pushTransition(using transitionContext: UIViewControllerContextTransitioning, curve: UIView.AnimationCurve = .easeOut) -> UIViewPropertyAnimator {
guard let toVC = transitionContext.viewController(forKey: .to) as? SearchDetailViewController,
let toView = transitionContext.view(forKey: .to) else {
fatalError()
@ -46,12 +46,14 @@ extension SearchToSearchDetailViewControllerAnimatedTransitioning {
let toViewEndFrame = transitionContext.finalFrame(for: toVC)
transitionContext.containerView.addSubview(toView)
toView.frame = toViewEndFrame
toView.alpha = 0
let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), curve: curve)
animator.addAnimations {
}
animator.addCompletion { position in
toView.alpha = 1
transitionContext.completeTransition(true)
}
return animator

View File

@ -26,4 +26,13 @@ extension SearchTransitionController: UINavigationControllerDelegate {
return nil
}
}
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
switch viewController {
case is SearchDetailViewController:
navigationController.interactivePopGestureRecognizer?.isEnabled = false
default:
navigationController.interactivePopGestureRecognizer?.isEnabled = true
}
}
}