feat: finish user favourite action
This commit is contained in:
parent
5f1800b353
commit
b55790fee8
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 = "<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>"; };
|
||||
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>"; };
|
||||
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 = "<group>";
|
||||
|
@ -521,6 +533,15 @@
|
|||
path = Item;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2DF75BB725D1473400694EC8 /* Stack */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */,
|
||||
2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */,
|
||||
);
|
||||
path = Stack;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 */,
|
||||
|
|
|
@ -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<Date, Never>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ import CoreDataStack
|
|||
import MastodonSDK
|
||||
|
||||
// MARK: - StatusProvider
|
||||
extension PublicTimelineViewController {
|
||||
extension PublicTimelineViewController: StatusProvider {
|
||||
|
||||
func toot() -> Future<Toot?, Never> {
|
||||
return Future { promise in promise(.success(nil)) }
|
||||
|
|
|
@ -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) {
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}()
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -87,6 +87,7 @@ extension Mastodon.API {
|
|||
public enum Instance { }
|
||||
public enum OAuth { }
|
||||
public enum Timeline { }
|
||||
public enum Favorites { }
|
||||
}
|
||||
|
||||
extension Mastodon.API {
|
||||
|
|
Loading…
Reference in New Issue