diff --git a/CoreDataStack/Entity/Toot.swift b/CoreDataStack/Entity/Toot.swift index 7719d6c03..fdb6cb5ff 100644 --- a/CoreDataStack/Entity/Toot.swift +++ b/CoreDataStack/Entity/Toot.swift @@ -150,6 +150,17 @@ public extension Toot { self.repliesCount = repliesCount } } + func update(liked: Bool, mastodonUser: MastodonUser) { + if liked { + if !(self.favouritedBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).addObjects(from: [mastodonUser]) + } + } else { + if (self.favouritedBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).remove(mastodonUser) + } + } + } func didUpdate(at networkDate: Date) { self.updatedAt = networkDate } diff --git a/CoreDataStack/Stack/ManagedObjectContextObjectsDidChange.swift b/CoreDataStack/Stack/ManagedObjectContextObjectsDidChange.swift new file mode 100644 index 000000000..980a2a5e1 --- /dev/null +++ b/CoreDataStack/Stack/ManagedObjectContextObjectsDidChange.swift @@ -0,0 +1,62 @@ +// +// ManagedObjectContextObjectsDidChange.swift +// CoreDataStack +// +// Created by sxiaojian on 2021/2/8. +// + +import Foundation +import CoreData + +public struct ManagedObjectContextObjectsDidChangeNotification { + + public let notification: Notification + public let managedObjectContext: NSManagedObjectContext + + public init?(notification: Notification) { + guard notification.name == .NSManagedObjectContextObjectsDidChange, + let managedObjectContext = notification.object as? NSManagedObjectContext else { + return nil + } + + self.notification = notification + self.managedObjectContext = managedObjectContext + } + +} + +extension ManagedObjectContextObjectsDidChangeNotification { + + public var insertedObjects: Set { + return objects(forKey: NSInsertedObjectsKey) + } + + public var updatedObjects: Set { + return objects(forKey: NSUpdatedObjectsKey) + } + + public var deletedObjects: Set { + return objects(forKey: NSDeletedObjectsKey) + } + + public var refreshedObjects: Set { + return objects(forKey: NSRefreshedObjectsKey) + } + + public var invalidedObjects: Set { + return objects(forKey: NSInvalidatedObjectsKey) + } + + public var invalidatedAllObjects: Bool { + return notification.userInfo?[NSInvalidatedAllObjectsKey] != nil + } + +} + +extension ManagedObjectContextObjectsDidChangeNotification { + + private func objects(forKey key: String) -> Set { + return notification.userInfo?[key] as? Set ?? Set() + } + +} diff --git a/CoreDataStack/Stack/ManagedObjectObserver.swift b/CoreDataStack/Stack/ManagedObjectObserver.swift new file mode 100644 index 000000000..3681fee95 --- /dev/null +++ b/CoreDataStack/Stack/ManagedObjectObserver.swift @@ -0,0 +1,80 @@ +// +// ManagedObjectObserver.swift +// CoreDataStack +// +// Created by sxiaojian on 2021/2/8. +// + +import Foundation +import CoreData +import Combine + +final public class ManagedObjectObserver { + private init() { } +} + +extension ManagedObjectObserver { + + public static func observe(object: NSManagedObject) -> AnyPublisher { + guard let context = object.managedObjectContext else { + return Fail(error: .noManagedObjectContext).eraseToAnyPublisher() + } + + return NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: context) + .tryMap { notification in + guard let notification = ManagedObjectContextObjectsDidChangeNotification(notification: notification) else { + throw Error.notManagedObjectChangeNotification + } + + let changeType = ManagedObjectObserver.changeType(of: object, in: notification) + return Change( + changeType: changeType, + changeNotification: notification + ) + } + .mapError { error -> Error in + return (error as? Error) ?? .unknown(error) + } + .eraseToAnyPublisher() + } + +} + +extension ManagedObjectObserver { + private static func changeType(of object: NSManagedObject, in notification: ManagedObjectContextObjectsDidChangeNotification) -> ChangeType? { + let deleted = notification.deletedObjects.union(notification.invalidedObjects) + if notification.invalidatedAllObjects || deleted.contains(where: { $0 === object }) { + return .delete + } + + let updated = notification.updatedObjects.union(notification.refreshedObjects) + if let object = updated.first(where: { $0 === object }) { + return .update(object) + } + + return nil + } +} + +extension ManagedObjectObserver { + public struct Change { + public let changeType: ChangeType? + public let changeNotification: ManagedObjectContextObjectsDidChangeNotification + + init(changeType: ManagedObjectObserver.ChangeType?, changeNotification: ManagedObjectContextObjectsDidChangeNotification) { + self.changeType = changeType + self.changeNotification = changeNotification + } + + } + public enum ChangeType { + case delete + case update(NSManagedObject) + } + + public enum Error: Swift.Error { + case unknown(Swift.Error) + case noManagedObjectContext + case notManagedObjectChangeNotification + } +} diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 45018ebda..375aa1b00 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -56,6 +56,11 @@ 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; }; 2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; }; 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF123A625C3B0210020F248 /* ActiveLabel.swift */; }; + 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */; }; + 2DF75BA125D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA025D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift */; }; + 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 */; }; 45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; }; 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; 5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; }; @@ -222,6 +227,11 @@ 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; 2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; 2DF123A625C3B0210020F248 /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = ""; }; + 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusProviderFacade.swift; sourceTree = ""; }; + 2DF75BA025D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+TimelinePostTableViewCellDelegate.swift"; sourceTree = ""; }; + 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 = ""; }; 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; }; 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -393,6 +403,8 @@ isa = PBXGroup; children = ( 2D38F1FD25CD481700561493 /* StatusProvider.swift */, + 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */, + 2DF75BA025D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift */, ); path = StatusProvider; sourceTree = ""; @@ -521,6 +533,15 @@ path = Item; sourceTree = ""; }; + 2DF75BB725D1473400694EC8 /* Stack */ = { + isa = PBXGroup; + children = ( + 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */, + 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */, + ); + path = Stack; + sourceTree = ""; + }; 3FE14AD363ED19AE7FF210A6 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -656,6 +677,7 @@ DB45FB0925CA87BC005A8AC7 /* CoreData */, 2D61335625C1887F00CAE157 /* Persist */, 2D61335D25C1894B00CAE157 /* APIService.swift */, + 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */, DB98337E25C9452D00AD9700 /* APIService+APIError.swift */, DB98336A25C9420100AD9700 /* APIService+App.swift */, DB98337025C9443200AD9700 /* APIService+Authentication.swift */, @@ -692,6 +714,7 @@ DB89B9F025C10FD0008580ED /* CoreDataStack.h */, DB89BA1125C1105C008580ED /* CoreDataStack.swift */, DB89BA3525C1145C008580ED /* CoreData.xcdatamodeld */, + 2DF75BB725D1473400694EC8 /* Stack */, DB89BA4025C1165F008580ED /* Protocol */, DB89BA1725C1107F008580ED /* Extension */, DB89BA2C25C110B7008580ED /* Entity */, @@ -1207,6 +1230,7 @@ DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, + 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, @@ -1216,6 +1240,7 @@ 2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, + 2DF75BA125D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, @@ -1243,6 +1268,7 @@ 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, 2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */, + 2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */, DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */, 2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */, @@ -1283,6 +1309,7 @@ files = ( 2DA7D05725CA693F00804E11 /* Application.swift in Sources */, 2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */, + 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */, DB89BA1225C1105C008580ED /* CoreDataStack.swift in Sources */, DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */, 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */, @@ -1295,6 +1322,7 @@ DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */, DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */, DB45FAED25CA7A9A005A8AC7 /* MastodonAuthentication.swift in Sources */, + 2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */, 2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */, DB89BA1D25C1107F008580ED /* URL.swift in Sources */, 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/TimelineSection.swift b/Mastodon/Diffiable/Section/TimelineSection.swift index bc0aed87b..59d4ce104 100644 --- a/Mastodon/Diffiable/Section/TimelineSection.swift +++ b/Mastodon/Diffiable/Section/TimelineSection.swift @@ -34,17 +34,18 @@ extension TimelineSection { // configure cell managedObjectContext.performAndWait { let timelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex - TimelineSection.configure(cell: cell, timestampUpdatePublisher: timestampUpdatePublisher, toot: timelineIndex.toot) + TimelineSection.configure(cell: cell, timestampUpdatePublisher: timestampUpdatePublisher, toot: timelineIndex.toot, requestUserID: timelineIndex.userID) } cell.delegate = timelinePostTableViewCellDelegate return cell case .toot(let objectID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelinePostTableViewCell.self), for: indexPath) as! TimelinePostTableViewCell - + let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value + let requestUserID = activeMastodonAuthenticationBox?.userID ?? "" // configure cell managedObjectContext.performAndWait { let toot = managedObjectContext.object(with: objectID) as! Toot - TimelineSection.configure(cell: cell, timestampUpdatePublisher: timestampUpdatePublisher, toot: toot) + TimelineSection.configure(cell: cell, timestampUpdatePublisher: timestampUpdatePublisher, toot: toot, requestUserID:requestUserID) } cell.delegate = timelinePostTableViewCellDelegate return cell @@ -69,7 +70,8 @@ extension TimelineSection { static func configure( cell: TimelinePostTableViewCell, timestampUpdatePublisher: AnyPublisher, - toot: Toot + toot: Toot, + requestUserID: String ) { // set name username avatar cell.timelinePostView.nameLabel.text = toot.author.displayName @@ -81,6 +83,15 @@ extension TimelineSection { ) // set text cell.timelinePostView.activeTextLabel.config(content: toot.content) + + // toolbar + let isLike = (toot.reblog ?? toot).favouritedBy.flatMap({ $0.contains(where: { $0.id == requestUserID }) }) ?? false + let favoriteCountTitle: String = { + let count = (toot.reblog ?? toot).favouritesCount.intValue + return TimelineSection.formattedNumberTitleForActionButton(count) + }() + cell.timelinePostView.actionToolbarContainer.starButton.setTitle(favoriteCountTitle, for: .normal) + cell.timelinePostView.actionToolbarContainer.isStarButtonHighlight = isLike // set date let createdAt = (toot.reblog ?? toot).createdAt timestampUpdatePublisher @@ -88,6 +99,25 @@ extension TimelineSection { cell.timelinePostView.dateLabel.text = createdAt.shortTimeAgoSinceNow } .store(in: &cell.disposeBag) + + // observe model change + ManagedObjectObserver.observe(object: toot.reblog ?? toot) + .receive(on: DispatchQueue.main) + .sink { _ in + // do nothing + } receiveValue: { change in + guard case let .update(object) = change.changeType, + let newToot = object as? Toot else { return } + let targetToot = newToot.reblog ?? newToot + + let isLike = targetToot.favouritedBy.flatMap({ $0.contains(where: { $0.id == requestUserID }) }) ?? false + let favoriteCount = targetToot.favouritesCount.intValue + let favoriteCountTitle = TimelineSection.formattedNumberTitleForActionButton(favoriteCount) + cell.timelinePostView.actionToolbarContainer.starButton.setTitle(favoriteCountTitle, for: .normal) + cell.timelinePostView.actionToolbarContainer.isStarButtonHighlight = isLike + os_log("%{public}s[%{public}ld], %{public}s: like count label for tweet %s did update: %ld", ((#file as NSString).lastPathComponent), #line, #function, targetToot.id, favoriteCount ) + } + .store(in: &cell.disposeBag) } } diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift new file mode 100644 index 000000000..9b50071f7 --- /dev/null +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift @@ -0,0 +1,24 @@ +// +// StatusProvider+TimelinePostTableViewCellDelegate.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/8. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK +import ActiveLabel + +// MARK: - ActionToolbarContainerDelegate +extension TimelinePostTableViewCellDelegate where Self: StatusProvider { + + func timelinePostTableViewCell(_ cell: TimelinePostTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) { + StatusProviderFacade.responseToStatusLikeAction(provider: self, cell: cell) + } + + +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift new file mode 100644 index 000000000..eaf202c01 --- /dev/null +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -0,0 +1,129 @@ +// +// StatusProviderFacade.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/8. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK +import ActiveLabel + +enum StatusProviderFacade { + +} +extension StatusProviderFacade { + + static func responseToStatusLikeAction(provider: StatusProvider) { + _responseToStatusLikeAction( + provider: provider, + toot: provider.toot() + ) + } + + static func responseToStatusLikeAction(provider: StatusProvider, cell: UITableViewCell) { + _responseToStatusLikeAction( + provider: provider, + toot: provider.toot(for: cell, indexPath: nil) + ) + } + + private static func _responseToStatusLikeAction(provider: StatusProvider, toot: Future) { + // prepare authentication + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + return + } + + // prepare current user infos + guard let _currentMastodonUser = provider.context.authenticationService.activeMastodonAuthentication.value?.user else { + assertionFailure() + return + } + let mastodonUserID = activeMastodonAuthenticationBox.userID + assert(_currentMastodonUser.id == mastodonUserID) + let mastodonUserObjectID = _currentMastodonUser.objectID + + guard let context = provider.context else { return } + + // haptic feedback generator + let generator = UIImpactFeedbackGenerator(style: .light) + let responseFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) + + toot + .compactMap { toot -> (NSManagedObjectID, Mastodon.API.Favorites.FavoriteKind)? in + guard let toot = toot else { return nil } + let favoriteKind: Mastodon.API.Favorites.FavoriteKind = { + let targetToot = (toot.reblog ?? toot) + let isLiked = targetToot.favouritedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false + return isLiked ? .destroy : .create + }() + return (toot.objectID, favoriteKind) + } + .map { tootObjectID, favoriteKind -> AnyPublisher<(Toot.ID, Mastodon.API.Favorites.FavoriteKind), Error> in + return context.apiService.like( + tootObjectID: tootObjectID, + mastodonUserObjectID: mastodonUserObjectID, + favoriteKind: favoriteKind + ) + .map { tootID in (tootID, favoriteKind) } + .eraseToAnyPublisher() + } + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + .switchToLatest() + .receive(on: DispatchQueue.main) + .handleEvents { _ in + generator.prepare() + responseFeedbackGenerator.prepare() + } receiveOutput: { _, favoriteKind in + generator.impactOccurred() + os_log("%{public}s[%{public}ld], %{public}s: [Like] update local toot like status to: %s", ((#file as NSString).lastPathComponent), #line, #function, favoriteKind == .create ? "like" : "unlike") + } receiveCompletion: { completion in + switch completion { + case .failure(let error): + // TODO: handle error + break + case .finished: + break + } + } + .map { tootID, favoriteKind in + return context.apiService.like( + statusID: tootID, + favoriteKind: favoriteKind, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .switchToLatest() + .receive(on: DispatchQueue.main) + .sink { [weak provider] completion in + guard let provider = provider else { return } + if provider.view.window != nil { + responseFeedbackGenerator.impactOccurred() + } + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: [Like] remote like request fail: %{public}s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + os_log("%{public}s[%{public}ld], %{public}s: [Like] remote like request success", ((#file as NSString).lastPathComponent), #line, #function) + } + } receiveValue: { response in + // do nothing + } + .store(in: &provider.disposeBag) + } + +} + +extension StatusProviderFacade { + enum Target { + case toot + case reblog + } +} + diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift index cb0390f85..889e9c6f5 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift @@ -12,7 +12,7 @@ import CoreDataStack import MastodonSDK // MARK: - StatusProvider -extension PublicTimelineViewController { +extension PublicTimelineViewController: StatusProvider { func toot() -> Future { return Future { promise in promise(.success(nil)) } diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelinePostTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelinePostTableViewCell.swift index f28bf7d8e..9a513dd79 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelinePostTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelinePostTableViewCell.swift @@ -12,7 +12,7 @@ import Combine protocol TimelinePostTableViewCellDelegate: class { - + func timelinePostTableViewCell(_ cell: TimelinePostTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) } final class TimelinePostTableViewCell: UITableViewCell { @@ -61,6 +61,26 @@ extension TimelinePostTableViewCell { contentView.readableContentGuide.trailingAnchor.constraint(equalTo: timelinePostView.trailingAnchor), contentView.bottomAnchor.constraint(equalTo: timelinePostView.bottomAnchor), // use action toolbar margin ]) + + timelinePostView.actionToolbarContainer.delegate = self } } +// MARK: - ActionToolbarContainerDelegate +extension TimelinePostTableViewCell: ActionToolbarContainerDelegate { + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) { + + } + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, retootButtonDidPressed sender: UIButton) { + + } + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, starButtonDidPressed sender: UIButton) { + delegate?.timelinePostTableViewCell(self, actionToolbarContainer: actionToolbarContainer, likeButtonDidPressed: sender) + } + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, bookmarkButtonDidPressed sender: UIButton) { + + } + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, moreButtonDidPressed sender: UIButton) { + + } +} diff --git a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift index eff1cb022..4be522b41 100644 --- a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift +++ b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift @@ -26,8 +26,8 @@ final class ActionToolbarContainer: UIView { let bookmartButton = HitTestExpandedButton() let moreButton = HitTestExpandedButton() - var isstarButtonHighlight: Bool = false { - didSet { isstarButtonHighlightStateDidChange(to: isstarButtonHighlight) } + var isStarButtonHighlight: Bool = false { + didSet { isStarButtonHighlightStateDidChange(to: isStarButtonHighlight) } } weak var delegate: ActionToolbarContainerDelegate? @@ -164,7 +164,7 @@ extension ActionToolbarContainer { return oldStyle != style } - private func isstarButtonHighlightStateDidChange(to isHighlight: Bool) { + private func isStarButtonHighlightStateDidChange(to isHighlight: Bool) { let tintColor = isHighlight ? Asset.Colors.systemOrange.color : Asset.Colors.Label.secondary.color starButton.tintColor = tintColor starButton.setTitleColor(tintColor, for: .normal) diff --git a/Mastodon/Service/APIService/APIService+Favorite.swift b/Mastodon/Service/APIService/APIService+Favorite.swift new file mode 100644 index 000000000..69c78ed75 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Favorite.swift @@ -0,0 +1,162 @@ +// +// APIService+Favorite.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/8. +// + + +import Foundation +import Combine +import MastodonSDK +import CoreData +import CoreDataStack +import CommonOSLog + +extension APIService { + + // make local state change only + func like( + tootObjectID: NSManagedObjectID, + mastodonUserObjectID: NSManagedObjectID, + favoriteKind: Mastodon.API.Favorites.FavoriteKind + ) -> AnyPublisher { + var _targetTootID: Toot.ID? + let managedObjectContext = backgroundManagedObjectContext + return managedObjectContext.performChanges { + let toot = managedObjectContext.object(with: tootObjectID) as! Toot + let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser + let targetToot = toot.reblog ?? toot + let targetTootID = targetToot.id + _targetTootID = targetTootID + + targetToot.update(liked: favoriteKind == .create, mastodonUser: mastodonUser) + + } + .tryMap { result in + switch result { + case .success: + guard let targetTootID = _targetTootID else { + throw APIError.implicit(.badRequest) + } + return targetTootID + + case .failure(let error): + assertionFailure(error.localizedDescription) + throw error + } + } + .eraseToAnyPublisher() + } + + // send favorite request to remote + func like( + statusID: Mastodon.Entity.Status.ID, + favoriteKind: Mastodon.API.Favorites.FavoriteKind, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + let requestMastodonUserID = mastodonAuthenticationBox.userID + return Mastodon.API.Favorites.favorites(domain: mastodonAuthenticationBox.domain, statusID: statusID, session: session, authorization: authorization, favoriteKind: favoriteKind) + .map { response -> AnyPublisher, Error> in + let log = OSLog.api + let entity = response.value + let managedObjectContext = self.backgroundManagedObjectContext + + return managedObjectContext.performChanges { + let _requestMastodonUser: MastodonUser? = { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: mastodonAuthenticationBox.domain, id: requestMastodonUserID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + let _oldToot: Toot? = { + let request = Toot.sortedFetchRequest + request.predicate = Toot.predicate(domain: mastodonAuthenticationBox.domain, id: entity.id) + request.returnsObjectsAsFaults = false + request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + guard let requestMastodonUser = _requestMastodonUser, + let oldToot = _oldToot else { + assertionFailure() + return + } + APIService.CoreData.mergeToot(for: requestMastodonUser, old: oldToot, in: mastodonAuthenticationBox.domain, entity: entity, networkDate: response.networkDate) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update toot %{public}s like status to: %{public}s. now %ld likes", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.favourited.flatMap { $0 ? "like" : "unlike" } ?? "", entity.favouritesCount ) + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .switchToLatest() + .handleEvents(receiveCompletion: { completion in + switch completion { + case .failure(let error): + print(error) + case .finished: + break + } + }) + .eraseToAnyPublisher() + } + +} + +extension APIService { + func likeList( + limit: Int = 200, + userID: String, + maxID: String? = nil, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + + let requestMastodonUserID = mastodonAuthenticationBox.userID + let query = Mastodon.API.Favorites.ListQuery(limit: limit, minID: nil, maxID: maxID) + return Mastodon.API.Favorites.getFavoriteStatus(domain: mastodonAuthenticationBox.domain, session: session, authorization: mastodonAuthenticationBox.userAuthorization, query: query) + .map { response -> AnyPublisher, Error> in + let log = OSLog.api + + return APIService.Persist.persistTimeline( + managedObjectContext: self.backgroundManagedObjectContext, + domain: mastodonAuthenticationBox.domain, + query: query as! TimelineQueryType, + response: response, + persistType: .likeList, + requestMastodonUserID: requestMastodonUserID, + log: log + ) + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .switchToLatest() + .eraseToAnyPublisher() + } +} diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift index e69cf9c31..3e2f8d3a3 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift @@ -73,11 +73,11 @@ extension APIService.CoreData { mentions: metions, emojis: emojis, tags: tags, - favouritedBy: requestMastodonUser, - rebloggedBy: requestMastodonUser, - mutedBy: requestMastodonUser, - bookmarkedBy: requestMastodonUser, - pinnedBy: requestMastodonUser + favouritedBy: (entity.favourited ?? false) ? mastodonUser : nil, + rebloggedBy: (entity.reblogged ?? false) ? mastodonUser : nil, + mutedBy: (entity.muted ?? false) ? mastodonUser : nil, + bookmarkedBy: (entity.bookmarked ?? false) ? mastodonUser : nil, + pinnedBy: (entity.pinned ?? false) ? mastodonUser : nil ) return (toot, true, isMastodonUserCreated) } diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift index 128da56e3..651a847d3 100644 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift @@ -18,6 +18,7 @@ extension APIService.Persist { enum PersistTimelineType { case `public` case home + case likeList } static func persistTimeline( @@ -92,6 +93,7 @@ extension APIService.Persist { switch persistType { case .public: return .publicTimeline case .home: return .homeTimeline + case .likeList: return .favoriteTimeline } }() diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift new file mode 100644 index 000000000..6942fa2f1 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift @@ -0,0 +1,106 @@ +// +// Mastodon+API+Favorites.swift +// +// +// Created by sxiaojian on 2021/2/7. +// + +import Combine +import Foundation + +extension Mastodon.API.Favorites { + static func favoritesStatusesEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("favourites") + } + + static func favoriteByUserListsEndpointURL(domain: String, statusID: String) -> URL { + let pathComponent = "statuses/" + statusID + "/favourited_by" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + static func favoriteActionEndpointURL(domain: String, statusID: String, favoriteKind: FavoriteKind) -> URL { + var actionString: String + switch favoriteKind { + case .create: + actionString = "/favourite" + case .destroy: + actionString = "/unfavourite" + } + let pathComponent = "statuses/" + statusID + actionString + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + public static func favorites(domain: String, statusID: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization, favoriteKind: FavoriteKind) -> AnyPublisher, Error> { + let url: URL = favoriteActionEndpointURL(domain: domain, statusID: statusID, favoriteKind: favoriteKind) + var request = Mastodon.API.post(url: url, query: nil, authorization: authorization) + request.httpMethod = "POST" + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public static func getFavoriteByUserLists(domain: String, statusID: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization) -> AnyPublisher, Error> { + let url = favoriteByUserListsEndpointURL(domain: domain, statusID: statusID) + let request = Mastodon.API.get(url: url, query: nil, authorization: authorization) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public static func getFavoriteStatus(domain: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization, query: Mastodon.API.Favorites.ListQuery) -> AnyPublisher, Error> { + let url = favoritesStatusesEndpointURL(domain: domain) + let request = Mastodon.API.get(url: url, query: query, authorization: authorization) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Status].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} + +public extension Mastodon.API.Favorites { + enum FavoriteKind { + case create + case destroy + } + + struct ListQuery: GetQuery,TimelineQueryType { + + public var limit: Int? + public var minID: String? + public var maxID: String? + public var sinceID: Mastodon.Entity.Status.ID? + + public init(limit: Int? = nil, minID: String? = nil, maxID: String? = nil, sinceID: String? = nil) { + self.limit = limit + self.minID = minID + self.maxID = maxID + self.sinceID = sinceID + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + if let limit = self.limit { + items.append(URLQueryItem(name: "limit", value: String(limit))) + } + if let minID = self.minID { + items.append(URLQueryItem(name: "min_id", value: minID)) + } + if let maxID = self.maxID { + items.append(URLQueryItem(name: "max_id", value: maxID)) + } + if let sinceID = self.sinceID { + items.append(URLQueryItem(name: "since_id", value: sinceID)) + } + guard !items.isEmpty else { return nil } + return items + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 900ebf965..98a96d1f7 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -87,6 +87,7 @@ extension Mastodon.API { public enum Instance { } public enum OAuth { } public enum Timeline { } + public enum Favorites { } } extension Mastodon.API {