feat: finish user favourite action

This commit is contained in:
sunxiaojian 2021-02-08 18:29:27 +08:00
parent 5f1800b353
commit b55790fee8
15 changed files with 669 additions and 14 deletions

View File

@ -150,6 +150,17 @@ public extension Toot {
self.repliesCount = repliesCount 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) { func didUpdate(at networkDate: Date) {
self.updatedAt = networkDate self.updatedAt = networkDate
} }

View File

@ -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<NSManagedObject> {
return objects(forKey: NSInsertedObjectsKey)
}
public var updatedObjects: Set<NSManagedObject> {
return objects(forKey: NSUpdatedObjectsKey)
}
public var deletedObjects: Set<NSManagedObject> {
return objects(forKey: NSDeletedObjectsKey)
}
public var refreshedObjects: Set<NSManagedObject> {
return objects(forKey: NSRefreshedObjectsKey)
}
public var invalidedObjects: Set<NSManagedObject> {
return objects(forKey: NSInvalidatedObjectsKey)
}
public var invalidatedAllObjects: Bool {
return notification.userInfo?[NSInvalidatedAllObjectsKey] != nil
}
}
extension ManagedObjectContextObjectsDidChangeNotification {
private func objects(forKey key: String) -> Set<NSManagedObject> {
return notification.userInfo?[key] as? Set<NSManagedObject> ?? Set()
}
}

View File

@ -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<Change, Error> {
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
}
}

View File

@ -56,6 +56,11 @@
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; }; 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; };
2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; }; 2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; };
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF123A625C3B0210020F248 /* ActiveLabel.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 */; }; 45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; };
5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; };
5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; }; 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 = "<group>"; }; 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; }; 2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
2DF123A625C3B0210020F248 /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = "<group>"; }; 2DF123A625C3B0210020F248 /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = "<group>"; };
2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusProviderFacade.swift; sourceTree = "<group>"; };
2DF75BA025D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+TimelinePostTableViewCellDelegate.swift"; sourceTree = "<group>"; };
2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Favorite.swift"; sourceTree = "<group>"; };
2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectObserver.swift; sourceTree = "<group>"; };
2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectContextObjectsDidChange.swift; sourceTree = "<group>"; };
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 = "<group>"; }; 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 = "<group>"; };
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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; }; 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; isa = PBXGroup;
children = ( children = (
2D38F1FD25CD481700561493 /* StatusProvider.swift */, 2D38F1FD25CD481700561493 /* StatusProvider.swift */,
2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */,
2DF75BA025D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift */,
); );
path = StatusProvider; path = StatusProvider;
sourceTree = "<group>"; sourceTree = "<group>";
@ -521,6 +533,15 @@
path = Item; path = Item;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
2DF75BB725D1473400694EC8 /* Stack */ = {
isa = PBXGroup;
children = (
2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */,
2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */,
);
path = Stack;
sourceTree = "<group>";
};
3FE14AD363ED19AE7FF210A6 /* Frameworks */ = { 3FE14AD363ED19AE7FF210A6 /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -656,6 +677,7 @@
DB45FB0925CA87BC005A8AC7 /* CoreData */, DB45FB0925CA87BC005A8AC7 /* CoreData */,
2D61335625C1887F00CAE157 /* Persist */, 2D61335625C1887F00CAE157 /* Persist */,
2D61335D25C1894B00CAE157 /* APIService.swift */, 2D61335D25C1894B00CAE157 /* APIService.swift */,
2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */,
DB98337E25C9452D00AD9700 /* APIService+APIError.swift */, DB98337E25C9452D00AD9700 /* APIService+APIError.swift */,
DB98336A25C9420100AD9700 /* APIService+App.swift */, DB98336A25C9420100AD9700 /* APIService+App.swift */,
DB98337025C9443200AD9700 /* APIService+Authentication.swift */, DB98337025C9443200AD9700 /* APIService+Authentication.swift */,
@ -692,6 +714,7 @@
DB89B9F025C10FD0008580ED /* CoreDataStack.h */, DB89B9F025C10FD0008580ED /* CoreDataStack.h */,
DB89BA1125C1105C008580ED /* CoreDataStack.swift */, DB89BA1125C1105C008580ED /* CoreDataStack.swift */,
DB89BA3525C1145C008580ED /* CoreData.xcdatamodeld */, DB89BA3525C1145C008580ED /* CoreData.xcdatamodeld */,
2DF75BB725D1473400694EC8 /* Stack */,
DB89BA4025C1165F008580ED /* Protocol */, DB89BA4025C1165F008580ED /* Protocol */,
DB89BA1725C1107F008580ED /* Extension */, DB89BA1725C1107F008580ED /* Extension */,
DB89BA2C25C110B7008580ED /* Entity */, DB89BA2C25C110B7008580ED /* Entity */,
@ -1207,6 +1230,7 @@
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */,
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */,
2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */,
DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */, DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */,
2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */,
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */,
@ -1216,6 +1240,7 @@
2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */, 2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */,
DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */,
DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */,
2DF75BA125D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift in Sources */,
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */,
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */,
DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */,
@ -1243,6 +1268,7 @@
2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */,
DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */,
2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */, 2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */,
2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */,
DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */,
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */, 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */,
2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */, 2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */,
@ -1283,6 +1309,7 @@
files = ( files = (
2DA7D05725CA693F00804E11 /* Application.swift in Sources */, 2DA7D05725CA693F00804E11 /* Application.swift in Sources */,
2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */, 2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */,
2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */,
DB89BA1225C1105C008580ED /* CoreDataStack.swift in Sources */, DB89BA1225C1105C008580ED /* CoreDataStack.swift in Sources */,
DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */, DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */,
2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */, 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */,
@ -1295,6 +1322,7 @@
DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */, DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */,
DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */, DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */,
DB45FAED25CA7A9A005A8AC7 /* MastodonAuthentication.swift in Sources */, DB45FAED25CA7A9A005A8AC7 /* MastodonAuthentication.swift in Sources */,
2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */,
2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */, 2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */,
DB89BA1D25C1107F008580ED /* URL.swift in Sources */, DB89BA1D25C1107F008580ED /* URL.swift in Sources */,
2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */, 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */,

View File

@ -34,17 +34,18 @@ extension TimelineSection {
// configure cell // configure cell
managedObjectContext.performAndWait { managedObjectContext.performAndWait {
let timelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex 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 cell.delegate = timelinePostTableViewCellDelegate
return cell return cell
case .toot(let objectID): case .toot(let objectID):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelinePostTableViewCell.self), for: indexPath) as! TimelinePostTableViewCell 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 // configure cell
managedObjectContext.performAndWait { managedObjectContext.performAndWait {
let toot = managedObjectContext.object(with: objectID) as! Toot 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 cell.delegate = timelinePostTableViewCellDelegate
return cell return cell
@ -69,7 +70,8 @@ extension TimelineSection {
static func configure( static func configure(
cell: TimelinePostTableViewCell, cell: TimelinePostTableViewCell,
timestampUpdatePublisher: AnyPublisher<Date, Never>, timestampUpdatePublisher: AnyPublisher<Date, Never>,
toot: Toot toot: Toot,
requestUserID: String
) { ) {
// set name username avatar // set name username avatar
cell.timelinePostView.nameLabel.text = toot.author.displayName cell.timelinePostView.nameLabel.text = toot.author.displayName
@ -81,6 +83,15 @@ extension TimelineSection {
) )
// set text // set text
cell.timelinePostView.activeTextLabel.config(content: toot.content) 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 // set date
let createdAt = (toot.reblog ?? toot).createdAt let createdAt = (toot.reblog ?? toot).createdAt
timestampUpdatePublisher timestampUpdatePublisher
@ -88,6 +99,25 @@ extension TimelineSection {
cell.timelinePostView.dateLabel.text = createdAt.shortTimeAgoSinceNow cell.timelinePostView.dateLabel.text = createdAt.shortTimeAgoSinceNow
} }
.store(in: &cell.disposeBag) .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)
} }
} }

View File

@ -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)
}
}

View File

@ -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<Toot?, Never>) {
// 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
}
}

View File

@ -12,7 +12,7 @@ import CoreDataStack
import MastodonSDK import MastodonSDK
// MARK: - StatusProvider // MARK: - StatusProvider
extension PublicTimelineViewController { extension PublicTimelineViewController: StatusProvider {
func toot() -> Future<Toot?, Never> { func toot() -> Future<Toot?, Never> {
return Future { promise in promise(.success(nil)) } return Future { promise in promise(.success(nil)) }

View File

@ -12,7 +12,7 @@ import Combine
protocol TimelinePostTableViewCellDelegate: class { protocol TimelinePostTableViewCellDelegate: class {
func timelinePostTableViewCell(_ cell: TimelinePostTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton)
} }
final class TimelinePostTableViewCell: UITableViewCell { final class TimelinePostTableViewCell: UITableViewCell {
@ -61,6 +61,26 @@ extension TimelinePostTableViewCell {
contentView.readableContentGuide.trailingAnchor.constraint(equalTo: timelinePostView.trailingAnchor), contentView.readableContentGuide.trailingAnchor.constraint(equalTo: timelinePostView.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: timelinePostView.bottomAnchor), // use action toolbar margin 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) {
}
}

View File

@ -26,8 +26,8 @@ final class ActionToolbarContainer: UIView {
let bookmartButton = HitTestExpandedButton() let bookmartButton = HitTestExpandedButton()
let moreButton = HitTestExpandedButton() let moreButton = HitTestExpandedButton()
var isstarButtonHighlight: Bool = false { var isStarButtonHighlight: Bool = false {
didSet { isstarButtonHighlightStateDidChange(to: isstarButtonHighlight) } didSet { isStarButtonHighlightStateDidChange(to: isStarButtonHighlight) }
} }
weak var delegate: ActionToolbarContainerDelegate? weak var delegate: ActionToolbarContainerDelegate?
@ -164,7 +164,7 @@ extension ActionToolbarContainer {
return oldStyle != style 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 let tintColor = isHighlight ? Asset.Colors.systemOrange.color : Asset.Colors.Label.secondary.color
starButton.tintColor = tintColor starButton.tintColor = tintColor
starButton.setTitleColor(tintColor, for: .normal) starButton.setTitleColor(tintColor, for: .normal)

View File

@ -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<Toot.ID, Error> {
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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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" } ?? "<nil>", entity.favouritesCount )
}
.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()
.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<Mastodon.Response.Content<[Mastodon.Entity.Status]>, 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<Mastodon.Response.Content<[Mastodon.Entity.Status]>, 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()
}
}

View File

@ -73,11 +73,11 @@ extension APIService.CoreData {
mentions: metions, mentions: metions,
emojis: emojis, emojis: emojis,
tags: tags, tags: tags,
favouritedBy: requestMastodonUser, favouritedBy: (entity.favourited ?? false) ? mastodonUser : nil,
rebloggedBy: requestMastodonUser, rebloggedBy: (entity.reblogged ?? false) ? mastodonUser : nil,
mutedBy: requestMastodonUser, mutedBy: (entity.muted ?? false) ? mastodonUser : nil,
bookmarkedBy: requestMastodonUser, bookmarkedBy: (entity.bookmarked ?? false) ? mastodonUser : nil,
pinnedBy: requestMastodonUser pinnedBy: (entity.pinned ?? false) ? mastodonUser : nil
) )
return (toot, true, isMastodonUserCreated) return (toot, true, isMastodonUserCreated)
} }

View File

@ -18,6 +18,7 @@ extension APIService.Persist {
enum PersistTimelineType { enum PersistTimelineType {
case `public` case `public`
case home case home
case likeList
} }
static func persistTimeline( static func persistTimeline(
@ -92,6 +93,7 @@ extension APIService.Persist {
switch persistType { switch persistType {
case .public: return .publicTimeline case .public: return .publicTimeline
case .home: return .homeTimeline case .home: return .homeTimeline
case .likeList: return .favoriteTimeline
} }
}() }()

View File

@ -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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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<Mastodon.Response.Content<[Mastodon.Entity.Account]>, 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<Mastodon.Response.Content<[Mastodon.Entity.Status]>, 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
}
}
}

View File

@ -87,6 +87,7 @@ extension Mastodon.API {
public enum Instance { } public enum Instance { }
public enum OAuth { } public enum OAuth { }
public enum Timeline { } public enum Timeline { }
public enum Favorites { }
} }
extension Mastodon.API { extension Mastodon.API {