diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 5cd582bf..e8d8dd60 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -1,5 +1,12 @@ - + + + + + + + + @@ -95,6 +102,7 @@ + @@ -109,13 +117,14 @@ + + - \ No newline at end of file diff --git a/CoreDataStack/Entity/Application.swift b/CoreDataStack/Entity/Application.swift new file mode 100644 index 00000000..cfbf48f7 --- /dev/null +++ b/CoreDataStack/Entity/Application.swift @@ -0,0 +1,61 @@ +// +// Application.swift +// CoreDataStack +// +// Created by sxiaojian on 2021/2/3. +// + +import CoreData +import Foundation + +public final class Application: NSManagedObject { + public typealias ID = UUID + @NSManaged public private(set) var identifier: ID + @NSManaged public private(set) var createAt: Date + + @NSManaged public private(set) var name: String + @NSManaged public private(set) var website: String? + @NSManaged public private(set) var vapidKey: String? + + // one-to-many relationship + @NSManaged public private(set) var toots: Set +} + +public extension Application { + override func awakeFromInsert() { + super.awakeFromInsert() + identifier = UUID() + } + + @discardableResult + static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> Application { + let app: Application = context.insertObject() + app.name = property.name + app.website = property.website + app.vapidKey = property.vapidKey + return app + } +} + +public extension Application { + struct Property { + public let name: String + public let website: String? + public let vapidKey: String? + + public init(name: String, website: String?, vapidKey: String?) { + self.name = name + self.website = website + self.vapidKey = vapidKey + } + } +} + +extension Application: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \Application.createAt, ascending: false)] + } +} diff --git a/CoreDataStack/Entity/Toot.swift b/CoreDataStack/Entity/Toot.swift index a0908d9d..c43d0d6c 100644 --- a/CoreDataStack/Entity/Toot.swift +++ b/CoreDataStack/Entity/Toot.swift @@ -21,6 +21,7 @@ public final class Toot: NSManagedObject { @NSManaged public private(set) var visibility: String? @NSManaged public private(set) var sensitive: Bool @NSManaged public private(set) var spoilerText: String? + @NSManaged public private(set) var application: Application? // Informational @NSManaged public private(set) var reblogsCount: NSNumber @@ -88,6 +89,8 @@ public extension Toot { toot.sensitive = property.sensitive toot.spoilerText = property.spoilerText + toot.application = property.application + if let mentions = property.mentions { toot.mutableSetValue(forKey: #keyPath(Toot.mentions)).addObjects(from: mentions) } @@ -123,11 +126,9 @@ public extension Toot { if let bookmarkedBy = property.bookmarkedBy { toot.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).add(bookmarkedBy) } - - // TODO: not implement yet - // if let pinnedBy = property.pinnedBy { - // toot.mutableSetValue(forKey: #keyPath(Toot.pinnedBy)) - // } + if let pinnedBy = property.pinnedBy { + toot.mutableSetValue(forKey: #keyPath(Toot.pinnedBy)).add(pinnedBy) + } toot.updatedAt = property.updatedAt toot.deletedAt = property.deletedAt @@ -137,6 +138,28 @@ public extension Toot { return toot } + func update(reblogsCount: NSNumber) { + if self.reblogsCount.intValue != reblogsCount.intValue { + self.reblogsCount = reblogsCount + } + } + func update(favouritesCount: NSNumber) { + if self.favouritesCount.intValue != favouritesCount.intValue { + self.favouritesCount = favouritesCount + } + } + func update(repliesCount: NSNumber?) { + guard let count = repliesCount else { + return + } + if self.repliesCount?.intValue != count.intValue { + self.repliesCount = repliesCount + } + } + func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + } public extension Toot { @@ -150,6 +173,7 @@ public extension Toot { visibility: String?, sensitive: Bool, spoilerText: String?, + application: Application?, mentions: [Mention]?, emojis: [Emoji]?, tags: [Tag]?, @@ -181,6 +205,7 @@ public extension Toot { self.visibility = visibility self.sensitive = sensitive self.spoilerText = spoilerText + self.application = application self.mentions = mentions self.emojis = emojis self.tags = tags @@ -215,6 +240,7 @@ public extension Toot { public let visibility: String? public let sensitive: Bool public let spoilerText: String? + public let application: Application? public let mentions: [Mention]? public let emojis: [Emoji]? diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index fb5c211e..e67aded3 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -16,11 +16,14 @@ 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */; }; 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */; }; 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; }; + 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */; }; 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */; }; 2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46976325C2A71500CF4AA9 /* UIIamge.swift */; }; 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */; }; 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; }; 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; }; + 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; }; + 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */; }; 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; }; 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */; }; 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */; }; @@ -32,6 +35,9 @@ 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0725C7E9A8004F19B8 /* Tag.swift */; }; 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0D25C7E9C9004F19B8 /* History.swift */; }; 2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F1325C7EDD9004F19B8 /* Emoji.swift */; }; + 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; }; + 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 */; }; 3533495136D843E85211E3E2 /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1B4523A7981F1044DE89C21 /* Pods_Mastodon_MastodonUITests.framework */; }; 45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; }; @@ -153,10 +159,13 @@ 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionToolBarContainer.swift; sourceTree = ""; }; 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HitTestExpandedButton.swift; sourceTree = ""; }; 2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = ""; }; + 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = ""; }; 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = ""; }; 2D46976325C2A71500CF4AA9 /* UIIamge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIIamge.swift; sourceTree = ""; }; 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = ""; }; 2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; + 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; + 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Toot.swift"; sourceTree = ""; }; 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = ""; }; 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewModel.swift; sourceTree = ""; }; 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewController+StatusProvider.swift"; sourceTree = ""; }; @@ -168,6 +177,10 @@ 2D927F0725C7E9A8004F19B8 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; 2D927F0D25C7E9C9004F19B8 /* History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = History.swift; sourceTree = ""; }; 2D927F1325C7EDD9004F19B8 /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; + 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLoaderTableViewCell.swift; sourceTree = ""; }; + 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = ""; }; + 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 = ""; }; 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; }; @@ -350,6 +363,14 @@ path = Persist; sourceTree = ""; }; + 2D69CFF225CA9E2200C3A1B2 /* Protocol */ = { + isa = PBXGroup; + children = ( + 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */, + ); + path = Protocol; + sourceTree = ""; + }; 2D76316325C14BAC00929FB9 /* PublicTimeline */ = { isa = PBXGroup; children = ( @@ -357,6 +378,7 @@ 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */, 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */, 2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */, + 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */, ); path = PublicTimeline; sourceTree = ""; @@ -403,6 +425,8 @@ 602D783BEC22881EBAD84419 /* Pods_Mastodon.framework */, A1B4523A7981F1044DE89C21 /* Pods_Mastodon_MastodonUITests.framework */, 2D7631A725C1535600929FB9 /* TimelinePostTableViewCell.swift */, + 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */, + 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */, ); path = TableviewCell; sourceTree = ""; @@ -500,6 +524,7 @@ children = ( DB427DE325BAA00100D1B89D /* Info.plist */, DB89BA1025C10FF5008580ED /* Mastodon.entitlements */, + 2D69CFF225CA9E2200C3A1B2 /* Protocol */, 2D76319C25C151DE00929FB9 /* Diffiable */, DB8AF52A25C13561002E6C99 /* State */, 2D61335525C1886800CAE157 /* Service */, @@ -549,6 +574,7 @@ DB45FB0925CA87BC005A8AC7 /* CoreData */ = { isa = PBXGroup; children = ( + 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */, DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */, DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */, ); @@ -600,6 +626,7 @@ 2D927F0D25C7E9C9004F19B8 /* History.swift */, 2D927F1325C7EDD9004F19B8 /* Emoji.swift */, DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */, + 2DA7D05625CA693F00804E11 /* Application.swift */, ); path = Entity; sourceTree = ""; @@ -673,6 +700,7 @@ isa = PBXGroup; children = ( CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */, + 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */, ); name = "Recovered References"; sourceTree = ""; @@ -1041,12 +1069,15 @@ 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, + 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */, DB98338825C945ED00AD9700 /* Assets.swift in Sources */, + 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, + 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */, 2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, @@ -1069,10 +1100,12 @@ DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, 2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */, DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, + 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */, DB8AF55725C137A8002E6C99 /* HomeViewController.swift in Sources */, 2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */, 2D7631A825C1535600929FB9 /* TimelinePostTableViewCell.swift in Sources */, 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */, + 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */, DB01409625C40B6700F9F3CF /* AuthenticationViewController.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, @@ -1100,6 +1133,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2DA7D05725CA693F00804E11 /* Application.swift in Sources */, 2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */, DB89BA1225C1105C008580ED /* CoreDataStack.swift in Sources */, DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index d04261a3..3113bc5e 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -5,16 +5,17 @@ // Created by sxiaojian on 2021/1/27. // -import Foundation import CoreData -import MastodonSDK import CoreDataStack +import Foundation +import MastodonSDK /// Note: update Equatable when change case enum Item { - // normal list case toot(objectID: NSManagedObjectID) + + case bottomLoader } extension Item: Equatable { @@ -22,6 +23,10 @@ extension Item: Equatable { switch (lhs, rhs) { case (.toot(let objectIDLeft), .toot(let objectIDRight)): return objectIDLeft == objectIDRight + case (.bottomLoader, .bottomLoader): + return true + default: + return false } } } @@ -31,7 +36,8 @@ extension Item: Hashable { switch self { case .toot(let objectID): hasher.combine(objectID) + case .bottomLoader: + hasher.combine(String(describing: Item.bottomLoader.self)) } } } - diff --git a/Mastodon/Diffiable/Section/TimelineSection.swift b/Mastodon/Diffiable/Section/TimelineSection.swift index 881b5154..50fbdd52 100644 --- a/Mastodon/Diffiable/Section/TimelineSection.swift +++ b/Mastodon/Diffiable/Section/TimelineSection.swift @@ -37,6 +37,10 @@ extension TimelineSection { } cell.delegate = timelinePostTableViewCellDelegate return cell + case .bottomLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell + cell.activityIndicatorView.startAnimating() + return cell } } } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index e06859b3..3399d735 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -10,6 +10,15 @@ import Foundation // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces internal enum L10n { + + internal enum Common { + internal enum Controls { + internal enum Timeline { + /// Load More + internal static let loadMore = L10n.tr("Localizable", "Common.Controls.Timeline.LoadMore") + } + } + } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces diff --git a/Mastodon/Protocol/LoadMoreConfigurableTableViewContainer.swift b/Mastodon/Protocol/LoadMoreConfigurableTableViewContainer.swift new file mode 100644 index 00000000..bc90ab1e --- /dev/null +++ b/Mastodon/Protocol/LoadMoreConfigurableTableViewContainer.swift @@ -0,0 +1,42 @@ +// +// LoadMoreConfigurableTableViewContainer.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/3. +// + +import UIKit +import GameplayKit + +/// The tableView container driven by state machines with "LoadMore" logic +protocol LoadMoreConfigurableTableViewContainer: UIViewController { + + associatedtype BottomLoaderTableViewCell: UITableViewCell + associatedtype LoadingState: GKState + + var loadMoreConfigurableTableView: UITableView { get } + var loadMoreConfigurableStateMachine: GKStateMachine { get } + func handleScrollViewDidScroll(_ scrollView: UIScrollView) +} + +extension LoadMoreConfigurableTableViewContainer { + func handleScrollViewDidScroll(_ scrollView: UIScrollView) { + guard scrollView === loadMoreConfigurableTableView else { return } + + let cells = loadMoreConfigurableTableView.visibleCells.compactMap { $0 as? BottomLoaderTableViewCell } + guard let loaderTableViewCell = cells.first else { return } + + if let tabBar = tabBarController?.tabBar, let window = view.window { + let loaderTableViewCellFrameInWindow = loadMoreConfigurableTableView.convert(loaderTableViewCell.frame, to: nil) + let windowHeight = window.frame.height + let loaderAppear = (loaderTableViewCellFrameInWindow.origin.y + 0.8 * loaderTableViewCell.frame.height) < (windowHeight - tabBar.frame.height) + if loaderAppear { + loadMoreConfigurableStateMachine.enter(LoadingState.self) + } else { + // do nothing + } + } else { + loadMoreConfigurableStateMachine.enter(LoadingState.self) + } + } +} diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index ac78f76b..caa87e95 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -5,3 +5,4 @@ Created by MainasuK Cirno on 2021/1/22. */ +"Common.Controls.Timeline.LoadMore" = "Load More"; diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift index d27531cc..cb0390f8 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift @@ -38,6 +38,8 @@ extension PublicTimelineViewController { let toot = managedObjectContext.object(with: objectID) as? Toot promise(.success(toot)) } + default: + promise(.success(nil)) } } } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index ce3bcd29..042d1582 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -5,41 +5,56 @@ // Created by sxiaojian on 2021/1/27. // -import os.log -import UIKit import AVKit import Combine import CoreDataStack import GameplayKit +import os.log +import UIKit final class PublicTimelineViewController: UIViewController, NeedsDependency, TimelinePostTableViewCellDelegate { - weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() var viewModel: PublicTimelineViewModel! - + let refreshControl = UIRefreshControl() + lazy var tableView: UITableView = { let tableView = UITableView() tableView.register(TimelinePostTableViewCell.self, forCellReuseIdentifier: String(describing: TimelinePostTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .none return tableView }() deinit { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) } - } extension PublicTimelineViewController { - override func viewDidLoad() { super.viewDidLoad() + tableView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(PublicTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged) + // bind refresh control + viewModel.isFetchingLatestTimeline + .receive(on: DispatchQueue.main) + .sink { [weak self] isFetching in + guard let self = self else { return } + if !isFetching { + UIView.animate(withDuration: 0.5) { [weak self] in + guard let self = self else { return } + self.refreshControl.endRefreshing() + } + } + } + .store(in: &disposeBag) + tableView.translatesAutoresizingMaskIntoConstraints = false tableView.backgroundColor = Asset.Colors.tootDark.color view.addSubview(tableView) @@ -60,29 +75,35 @@ extension PublicTimelineViewController { timelinePostTableViewCellDelegate: self ) } + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - viewModel.fetchLatest() - .receive(on: DispatchQueue.main) - .sink { completion in - switch completion { - case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: fetch user timeline latest response error: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - case .finished: - break - } - } receiveValue: { response in - let tootsIDs = response.value.map { $0.id } - self.viewModel.tootIDs.value = tootsIDs - } - .store(in: &viewModel.disposeBag) + viewModel.stateMachine.enter(PublicTimelineViewModel.State.Loading.self) + } +} + +// MARK: - UIScrollViewDelegate +extension PublicTimelineViewController { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + handleScrollViewDidScroll(scrollView) + } + +} + +// MARK: - Selector + +extension PublicTimelineViewController { + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { + guard viewModel.stateMachine.enter(PublicTimelineViewModel.State.Loading.self) else { + sender.endRefreshing() + return + } } - } // MARK: - UITableViewDelegate + extension PublicTimelineViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } @@ -94,12 +115,9 @@ extension PublicTimelineViewController: UITableViewDelegate { return ceil(frame.height) } - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - - } + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {} func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } @@ -108,3 +126,11 @@ extension PublicTimelineViewController: UITableViewDelegate { viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key)) } } +// MARK: - LoadMoreConfigurableTableViewContainer +extension PublicTimelineViewController: LoadMoreConfigurableTableViewContainer { + typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell + typealias LoadingState = PublicTimelineViewModel.State.LoadingMore + + var loadMoreConfigurableTableView: UITableView { return tableView } + var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine } +} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift index c2ee8e5d..6e4e1069 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift @@ -5,10 +5,10 @@ // Created by sxiaojian on 2021/1/27. // -import os.log -import UIKit import CoreData import CoreDataStack +import os.log +import UIKit extension PublicTimelineViewModel { func setupDiffableDataSource( @@ -20,26 +20,27 @@ extension PublicTimelineViewModel { .autoconnect() .share() .eraseToAnyPublisher() - + diffableDataSource = TimelineSection.tableViewDiffableDataSource( for: tableView, dependency: dependency, managedObjectContext: fetchedResultsController.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, - timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate) + timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate + ) items.value = [] } } // MARK: - NSFetchedResultsControllerDelegate + extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate { func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - + os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) + let indexes = tootIDs.value let toots = fetchedResultsController.fetchedObjects ?? [] guard toots.count == indexes.count else { return } - let items: [Item] = toots .compactMap { toot -> (Int, Toot)? in guard toot.deletedAt == nil else { return nil } @@ -49,5 +50,4 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate { .map { Item.toot(objectID: $0.1.objectID) } self.items.value = items } - } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift new file mode 100644 index 00000000..3174015b --- /dev/null +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift @@ -0,0 +1,138 @@ +// +// PublicTimelineViewModel+State.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/2. +// + +import Foundation +import GameplayKit +import MastodonSDK +import os.log + +extension PublicTimelineViewModel { + class State: GKState { + weak var viewModel: PublicTimelineViewModel? + + init(viewModel: PublicTimelineViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", (#file as NSString).lastPathComponent, #line, #function, self.debugDescription, previousState.debugDescription) + } + } +} + +extension PublicTimelineViewModel.State { + class Initial: PublicTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + } + + class Loading: PublicTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Fail.Type: + return true + case is Idle.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + viewModel.fetchLatest() + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: fetch user timeline latest response error: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + stateMachine.enter(Fail.self) + + case .finished: + break + } + } receiveValue: { response in + viewModel.isFetchingLatestTimeline.value = false + let tootsIDs = response.value.map { $0.id } + viewModel.tootIDs.value = tootsIDs + stateMachine.enter(Idle.self) + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: PublicTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type, is LoadingMore.Type: + return true + default: + return false + } + } + } + + class Idle: PublicTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type, is LoadingMore.Type: + return true + default: + return false + } + } + } + + class LoadingMore: PublicTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Fail.Type: + return true + case is Idle.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + viewModel.loadMore() + .sink { completion in + switch completion { + case .failure(let error): + stateMachine.enter(Fail.self) + os_log("%{public}s[%{public}ld], %{public}s: load more fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + case .finished: + break + } + } receiveValue: { response in + stateMachine.enter(Idle.self) + var oldTootsIDs = viewModel.tootIDs.value + for toot in response.value { + if !oldTootsIDs.contains(toot.id) { + oldTootsIDs.append(toot.id) + } + } + + viewModel.tootIDs.value = oldTootsIDs + + } + .store(in: &viewModel.disposeBag) + } + } +} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift index 2150bb25..115a35f2 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift @@ -5,28 +5,39 @@ // Created by sxiaojian on 2021/1/27. // -import os.log -import UIKit -import GameplayKit +import AlamofireImage import Combine import CoreData import CoreDataStack +import GameplayKit import MastodonSDK -import AlamofireImage - +import os.log +import UIKit class PublicTimelineViewModel: NSObject { - var disposeBag = Set() // input let context: AppContext let fetchedResultsController: NSFetchedResultsController + let isFetchingLatestTimeline = CurrentValueSubject(false) weak var tableView: UITableView? // output var diffableDataSource: UITableViewDiffableDataSource? + lazy var stateMachine: GKStateMachine = { + let stateMachine = GKStateMachine(states: [ + State.Initial(viewModel: self), + State.Loading(viewModel: self), + State.Fail(viewModel: self), + State.Idle(viewModel: self), + State.LoadingMore(viewModel: self), + ]) + stateMachine.enter(State.Initial.self) + return stateMachine + }() + let tootIDs = CurrentValueSubject<[String], Never>([]) let items = CurrentValueSubject<[Item], Never>([]) var cellFrameCache = NSCache() @@ -49,7 +60,7 @@ class PublicTimelineViewModel: NSObject { }() super.init() - self.fetchedResultsController.delegate = self + fetchedResultsController.delegate = self items .receive(on: DispatchQueue.main) @@ -57,12 +68,19 @@ class PublicTimelineViewModel: NSObject { .sink { [weak self] items in guard let self = self else { return } guard let diffableDataSource = self.diffableDataSource else { return } - os_log("%{public}s[%{public}ld], %{public}s: items did change", ((#file as NSString).lastPathComponent), #line, #function) + os_log("%{public}s[%{public}ld], %{public}s: items did change", (#file as NSString).lastPathComponent, #line, #function) var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) snapshot.appendItems(items) - + if let currentState = self.stateMachine.currentState { + switch currentState { + case is State.Idle, is State.LoadingMore, is State.Fail: + snapshot.appendItems([.bottomLoader], toSection: .main) + default: + break + } + } diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty) } .store(in: &disposeBag) @@ -82,14 +100,16 @@ class PublicTimelineViewModel: NSObject { } deinit { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) } - } extension PublicTimelineViewModel { - func fetchLatest() -> AnyPublisher, Error> { return context.apiService.publicTimeline(count: 20, domain: "mstdn.jp") } + + func loadMore() -> AnyPublisher, Error> { + return context.apiService.publicTimeline(count: 20, domain: "mstdn.jp") + } } diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift new file mode 100644 index 00000000..f2892a84 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift @@ -0,0 +1,18 @@ +// +// TimelineBottomLoaderTableViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/3. +// + +import UIKit +import Combine + +final class TimelineBottomLoaderTableViewCell: TimelineLoaderTableViewCell { + override func _init() { + super._init() + + activityIndicatorView.isHidden = false + activityIndicatorView.startAnimating() + } +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift new file mode 100644 index 00000000..5fbaa5d7 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift @@ -0,0 +1,70 @@ +// +// TimelineLoaderTableViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/3. +// + +import UIKit +import Combine + +class TimelineLoaderTableViewCell: UITableViewCell { + + static let cellHeight: CGFloat = 48 + + var disposeBag = Set() + + let loadMoreButton: UIButton = { + let button = UIButton(type: .system) + button.titleLabel?.font = .preferredFont(forTextStyle: .headline) + button.setTitle(L10n.Common.Controls.Timeline.loadMore, for: .normal) + return button + }() + + let activityIndicatorView: UIActivityIndicatorView = { + let activityIndicatorView = UIActivityIndicatorView(style: .medium) + activityIndicatorView.tintColor = .white + activityIndicatorView.hidesWhenStopped = true + return activityIndicatorView + }() + + override func prepareForReuse() { + super.prepareForReuse() + disposeBag.removeAll() + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + func _init() { + selectionStyle = .none + backgroundColor = Asset.Colors.tootDark.color + loadMoreButton.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(loadMoreButton) + NSLayoutConstraint.activate([ + loadMoreButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + loadMoreButton.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + contentView.readableContentGuide.trailingAnchor.constraint(equalTo: loadMoreButton.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor, constant: 8), + loadMoreButton.heightAnchor.constraint(equalToConstant: TimelineLoaderTableViewCell.cellHeight - 2 * 8).priority(.defaultHigh), + ]) + + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + addSubview(activityIndicatorView) + NSLayoutConstraint.activate([ + activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor), + activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + + loadMoreButton.isHidden = true + activityIndicatorView.isHidden = true + } + +} diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift new file mode 100644 index 00000000..b4bdb28b --- /dev/null +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift @@ -0,0 +1,130 @@ +// +// APIService+CoreData+Toot.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/3. +// + +import Foundation +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService.CoreData { + + static func createOrMergeTweet( + into managedObjectContext: NSManagedObjectContext, + for requestMastodonUser: MastodonUser, + entity: Mastodon.Entity.Toot, + domain: String, + networkDate: Date, + log: OSLog + ) -> (Toot: Toot, isTweetCreated: Bool, isMastodonUserCreated: Bool) { + + // build tree + let reblog = entity.reblog.flatMap { entity -> Toot in + let (toot, _, _) = createOrMergeTweet(into: managedObjectContext, for: requestMastodonUser, entity: entity,domain: domain, networkDate: networkDate, log: log) + return toot + } + + // fetch old Toot + let oldTweet: Toot? = { + let request = Toot.sortedFetchRequest + request.predicate = Toot.predicate(idStr: entity.id) + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + if let oldTweet = oldTweet { + // merge old Toot + APIService.CoreData.mergeToot(for: requestMastodonUser, old: oldTweet,in: domain, entity: entity, networkDate: networkDate) + return (oldTweet, false, false) + } else { + + let (mastodonUser, isMastodonUserCreated) = createOrMergeMastodonUser(into: managedObjectContext, for: requestMastodonUser,in: domain, entity: entity.account, networkDate: networkDate, log: log) + let application = entity.application.flatMap { app -> Application? in + Application.insert(into: managedObjectContext, property: Application.Property(name: app.name, website: app.website, vapidKey: app.vapidKey)) + } + + let metions = entity.mentions?.compactMap({ (mention) -> Mention in + Mention.insert(into: managedObjectContext, property: Mention.Property(id: mention.id, username: mention.username, acct: mention.acct, url: mention.url)) + }) + let emojis = entity.emojis?.compactMap({ (emoji) -> Emoji in + Emoji.insert(into: managedObjectContext, property: Emoji.Property(shortcode: emoji.shortcode, url: emoji.url, staticURL: emoji.staticURL, visibleInPicker: emoji.visibleInPicker, category: emoji.category)) + }) + let tags = entity.tags?.compactMap({ (tag) -> Tag in + let histories = tag.history?.compactMap({ (history) -> History in + History.insert(into: managedObjectContext, property: History.Property(day: history.day, uses: history.uses, accounts: history.accounts)) + }) + return Tag.insert(into: managedObjectContext, property: Tag.Property(name: tag.name, url: tag.url, histories: histories)) + }) + let tootProperty = Toot.Property( + domain: domain, + id: entity.id, + uri: entity.uri, + createdAt: entity.createdAt, + content: entity.content, + visibility: entity.visibility?.rawValue, + sensitive: entity.sensitive ?? false, + spoilerText: entity.spoilerText, + application: application, + mentions: metions, + emojis: emojis, + tags: tags, + reblogsCount: NSNumber(value: entity.reblogsCount), + favouritesCount: NSNumber(value: entity.favouritesCount), + repliesCount: (entity.repliesCount != nil) ? NSNumber(value: entity.repliesCount!) : nil, + url: entity.uri, + inReplyToID: entity.inReplyToID, + inReplyToAccountID: entity.inReplyToAccountID, + reblog: reblog, + language: entity.language, + text: entity.text, + 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, + updatedAt: networkDate, + deletedAt: nil, + author: requestMastodonUser, + homeTimelineIndexes: nil) + let toot = Toot.insert(into: managedObjectContext, property: tootProperty, author: mastodonUser) + return (toot, true, isMastodonUserCreated) + } + } + static func mergeToot(for requestMastodonUser: MastodonUser?, old toot: Toot,in domain: String, entity: Mastodon.Entity.Toot, networkDate: Date) { + guard networkDate > toot.updatedAt else { return } + + // merge + if entity.favouritesCount != toot.favouritesCount.intValue { + toot.update(favouritesCount:NSNumber(value: entity.favouritesCount)) + } + if let repliesCount = entity.repliesCount { + if (repliesCount != toot.repliesCount?.intValue) { + toot.update(repliesCount:NSNumber(value: repliesCount)) + } + } + if entity.reblogsCount != toot.reblogsCount.intValue { + toot.update(reblogsCount:NSNumber(value: entity.reblogsCount)) + } + + + // set updateAt + toot.didUpdate(at: networkDate) + + // merge user + mergeMastodonUser(for: requestMastodonUser, old: toot.author, in: domain, entity: entity.account, networkDate: networkDate) + // merge indirect reblog & quote + if let reblog = toot.reblog, let reblogEntity = entity.reblog { + mergeToot(for: requestMastodonUser, old: reblog,in: domain, entity: reblogEntity, networkDate: networkDate) + } + } + +} diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift index d3e9ed5e..ec38e599 100644 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift @@ -27,6 +27,9 @@ extension APIService.Persist { let _ = toots.map { let userProperty = MastodonUser.Property(id: $0.account.id, domain: domain, acct: $0.account.acct, username: $0.account.username, displayName: $0.account.displayName,avatar: $0.account.avatar,avatarStatic: $0.account.avatarStatic, createdAt: $0.createdAt, networkDate: $0.createdAt) let author = MastodonUser.insert(into: managedObjectContext, property: userProperty) + let application = $0.application.flatMap { app -> Application? in + Application.insert(into: managedObjectContext, property: Application.Property(name: app.name, website: app.website, vapidKey: app.vapidKey)) + } let metions = $0.mentions?.compactMap({ (mention) -> Mention in Mention.insert(into: managedObjectContext, property: Mention.Property(id: mention.id, username: mention.username, acct: mention.acct, url: mention.url)) }) @@ -49,6 +52,7 @@ extension APIService.Persist { visibility: $0.visibility?.rawValue, sensitive: $0.sensitive ?? false, spoilerText: $0.spoilerText, + application: application, mentions: metions, emojis: emojis, tags: tags,