From 2ebb12b86eee0178bb86b3418237d515111d0a83 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 4 Feb 2021 14:45:44 +0800 Subject: [PATCH] feat: add APIService.Persist.persistTimeline method and make public timeline load oldest works --- .../CoreData.xcdatamodel/contents | 8 +- CoreDataStack/Entity/HomeTimelineIndex.swift | 19 +- CoreDataStack/Entity/MastodonUser.swift | 4 +- CoreDataStack/Entity/Toot.swift | 240 ++++----- CoreDataStack/Extension/Collection.swift | 1 - .../Extension/NSManagedObjectContext.swift | 1 - CoreDataStack/Protocol/Managed.swift | 1 - Mastodon.xcodeproj/project.pbxproj | 16 +- .../xcschemes/xcschememanagement.plist | 2 +- .../{ => CoreDataStack}/MastodonUser.swift | 0 Mastodon/Extension/CoreDataStack/Toot.swift | 34 ++ .../PublicTimelineViewController.swift | 7 - .../PublicTimelineViewModel+Diffable.swift | 1 + .../PublicTimelineViewModel+State.swift | 65 ++- .../PublicTimelineViewModel.swift | 8 +- .../APIService/APIService+HomeTimeline.swift | 12 +- .../APIService+PublicTimeline.swift | 8 +- .../CoreData/APIService+CoreData+Toot.swift | 77 ++- .../Persist/APIService+Persist+Timeline.swift | 461 +++++++++++++++--- Mastodon/Service/AuthenticationService.swift | 2 + .../API/Mastodon+API+Timeline.swift | 11 +- 21 files changed, 686 insertions(+), 292 deletions(-) rename Mastodon/Extension/{ => CoreDataStack}/MastodonUser.swift (100%) create mode 100644 Mastodon/Extension/CoreDataStack/Toot.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index e8d8dd605..c0e864dce 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -27,9 +27,11 @@ + + - + @@ -120,11 +122,11 @@ - + - \ No newline at end of file + diff --git a/CoreDataStack/Entity/HomeTimelineIndex.swift b/CoreDataStack/Entity/HomeTimelineIndex.swift index 7c4e4eb77..93fa0fb59 100644 --- a/CoreDataStack/Entity/HomeTimelineIndex.swift +++ b/CoreDataStack/Entity/HomeTimelineIndex.swift @@ -13,9 +13,13 @@ final public class HomeTimelineIndex: NSManagedObject { public typealias ID = String @NSManaged public private(set) var identifier: ID @NSManaged public private(set) var domain: String - @NSManaged public private(set) var userIdentifier: String + @NSManaged public private(set) var userID: String + + @NSManaged public private(set) var hasMore: Bool // default NO @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var deletedAt: Date? + // many-to-one relationship @NSManaged public private(set) var toot: Toot @@ -34,7 +38,7 @@ extension HomeTimelineIndex { index.identifier = property.identifier index.domain = property.domain - index.userIdentifier = toot.author.identifier + index.userID = toot.author.id index.createdAt = toot.createdAt index.toot = toot @@ -42,6 +46,17 @@ extension HomeTimelineIndex { return index } + public func update(hasMore: Bool) { + if self.hasMore != hasMore { + self.hasMore = hasMore + } + } + + // internal method for Toot call + func softDelete() { + deletedAt = Date() + } + } extension HomeTimelineIndex { diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index 8ac22c0bf..bcbfe5d26 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -37,9 +37,7 @@ final public class MastodonUser: NSManagedObject { @NSManaged public private(set) var reblogged: Set? @NSManaged public private(set) var muted: Set? @NSManaged public private(set) var bookmarked: Set? - - @NSManaged public private(set) var retweets: Set? - + } extension MastodonUser { diff --git a/CoreDataStack/Entity/Toot.swift b/CoreDataStack/Entity/Toot.swift index c43d0d6c3..6dab70104 100644 --- a/CoreDataStack/Entity/Toot.swift +++ b/CoreDataStack/Entity/Toot.swift @@ -10,6 +10,7 @@ import Foundation public final class Toot: NSManagedObject { public typealias ID = String + @NSManaged public private(set) var identifier: ID @NSManaged public private(set) var domain: String @@ -36,6 +37,8 @@ public final class Toot: NSManagedObject { @NSManaged public private(set) var text: String? // many-to-one relastionship + @NSManaged public private(set) var author: MastodonUser + @NSManaged public private(set) var reblog: Toot? @NSManaged public private(set) var favouritedBy: MastodonUser? @NSManaged public private(set) var rebloggedBy: MastodonUser? @NSManaged public private(set) var mutedBy: MastodonUser? @@ -43,29 +46,16 @@ public final class Toot: NSManagedObject { // one-to-one relastionship @NSManaged public private(set) var pinnedBy: MastodonUser? + + // one-to-many relationship + @NSManaged public private(set) var reblogFrom: Set? + @NSManaged public private(set) var mentions: Set? + @NSManaged public private(set) var emojis: Set? + @NSManaged public private(set) var tags: Set? + @NSManaged public private(set) var homeTimelineIndexes: Set? @NSManaged public private(set) var updatedAt: Date @NSManaged public private(set) var deletedAt: Date? - - // one-to-many relationship - @NSManaged public private(set) var reblogFrom: Set? - - // one-to-many relationship - @NSManaged public private(set) var mentions: Set? - // one-to-many relationship - @NSManaged public private(set) var emojis: Set? - - // one-to-many relationship - @NSManaged public private(set) var tags: Set? - - // many-to-one relastionship - @NSManaged public private(set) var reblog: Toot? - - // many-to-one relationship - @NSManaged public private(set) var author: MastodonUser - - // one-to-many relationship - @NSManaged public private(set) var homeTimelineIndexes: Set? } public extension Toot { @@ -73,7 +63,17 @@ public extension Toot { static func insert( into context: NSManagedObjectContext, property: Property, - author: MastodonUser + author: MastodonUser, + reblog: Toot?, + application: Application?, + mentions: [Mention]?, + emojis: [Emoji]?, + tags: [Tag]?, + favouritedBy: MastodonUser?, + rebloggedBy: MastodonUser?, + mutedBy: MastodonUser?, + bookmarkedBy: MastodonUser?, + pinnedBy: MastodonUser? ) -> Toot { let toot: Toot = context.insertObject() @@ -88,20 +88,7 @@ public extension Toot { toot.visibility = property.visibility 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) - } - - if let emojis = property.emojis { - toot.mutableSetValue(forKey: #keyPath(Toot.emojis)).addObjects(from: emojis) - } - - if let tags = property.tags { - toot.mutableSetValue(forKey: #keyPath(Toot.tags)).addObjects(from: tags) - } + toot.application = application toot.reblogsCount = property.reblogsCount toot.favouritesCount = property.favouritesCount @@ -110,31 +97,39 @@ public extension Toot { toot.url = property.url toot.inReplyToID = property.inReplyToID toot.inReplyToAccountID = property.inReplyToAccountID - toot.reblog = property.reblog + toot.language = property.language toot.text = property.text - if let favouritedBy = property.favouritedBy { + toot.author = author + toot.reblog = reblog + + if let mentions = mentions { + toot.mutableSetValue(forKey: #keyPath(Toot.mentions)).addObjects(from: mentions) + } + if let emojis = emojis { + toot.mutableSetValue(forKey: #keyPath(Toot.emojis)).addObjects(from: emojis) + } + if let tags = tags { + toot.mutableSetValue(forKey: #keyPath(Toot.tags)).addObjects(from: tags) + } + if let favouritedBy = favouritedBy { toot.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).add(favouritedBy) } - if let rebloggedBy = property.rebloggedBy { + if let rebloggedBy = rebloggedBy { toot.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).add(rebloggedBy) } - if let mutedBy = property.mutedBy { + if let mutedBy = mutedBy { toot.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).add(mutedBy) } - if let bookmarkedBy = property.bookmarkedBy { + if let bookmarkedBy = bookmarkedBy { toot.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).add(bookmarkedBy) } - if let pinnedBy = property.pinnedBy { + if let pinnedBy = pinnedBy { toot.mutableSetValue(forKey: #keyPath(Toot.pinnedBy)).add(pinnedBy) } - toot.updatedAt = property.updatedAt - toot.deletedAt = property.deletedAt - toot.author = property.author - toot.content = property.content - toot.homeTimelineIndexes = property.homeTimelineIndexes + toot.updatedAt = property.networkDate return toot } @@ -164,70 +159,6 @@ public extension Toot { public extension Toot { struct Property { - public init( - domain: String, - id: String, - uri: String, - createdAt: Date, - content: String, - visibility: String?, - sensitive: Bool, - spoilerText: String?, - application: Application?, - mentions: [Mention]?, - emojis: [Emoji]?, - tags: [Tag]?, - reblogsCount: NSNumber, - favouritesCount: NSNumber, - repliesCount: NSNumber?, - url: String?, - inReplyToID: Toot.ID?, - inReplyToAccountID: MastodonUser.ID?, - reblog: Toot?, - language: String?, - text: String?, - favouritedBy: MastodonUser?, - rebloggedBy: MastodonUser?, - mutedBy: MastodonUser?, - bookmarkedBy: MastodonUser?, - pinnedBy: MastodonUser?, - updatedAt: Date, - deletedAt: Date?, - author: MastodonUser, - homeTimelineIndexes: Set?) - { - self.identifier = id + "@" + domain - self.domain = domain - self.id = id - self.uri = uri - self.createdAt = createdAt - self.content = content - self.visibility = visibility - self.sensitive = sensitive - self.spoilerText = spoilerText - self.application = application - self.mentions = mentions - self.emojis = emojis - self.tags = tags - self.reblogsCount = reblogsCount - self.favouritesCount = favouritesCount - self.repliesCount = repliesCount - self.url = url - self.inReplyToID = inReplyToID - self.inReplyToAccountID = inReplyToAccountID - self.reblog = reblog - self.language = language - self.text = text - self.favouritedBy = favouritedBy - self.rebloggedBy = rebloggedBy - self.mutedBy = mutedBy - self.bookmarkedBy = bookmarkedBy - self.pinnedBy = pinnedBy - self.updatedAt = updatedAt - self.deletedAt = deletedAt - self.author = author - self.homeTimelineIndexes = homeTimelineIndexes - } public let identifier: ID public let domain: String @@ -240,11 +171,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]? - public let tags: [Tag]? public let reblogsCount: NSNumber public let favouritesCount: NSNumber public let repliesCount: NSNumber? @@ -252,22 +179,50 @@ public extension Toot { public let url: String? public let inReplyToID: Toot.ID? public let inReplyToAccountID: MastodonUser.ID? - public let reblog: Toot? public let language: String? // (ISO 639 Part @1 two-letter language code) public let text: String? + + public let networkDate: Date - public let favouritedBy: MastodonUser? - public let rebloggedBy: MastodonUser? - public let mutedBy: MastodonUser? - public let bookmarkedBy: MastodonUser? - public let pinnedBy: MastodonUser? + public init( + domain: String, + id: String, + uri: String, + createdAt: Date, + content: String, + visibility: String?, + sensitive: Bool, + spoilerText: String?, + reblogsCount: NSNumber, + favouritesCount: NSNumber, + repliesCount: NSNumber?, + url: String?, + inReplyToID: Toot.ID?, + inReplyToAccountID: MastodonUser.ID?, + language: String?, + text: String?, + networkDate: Date + ) { + self.identifier = id + "@" + domain + self.domain = domain + self.id = id + self.uri = uri + self.createdAt = createdAt + self.content = content + self.visibility = visibility + self.sensitive = sensitive + self.spoilerText = spoilerText + self.reblogsCount = reblogsCount + self.favouritesCount = favouritesCount + self.repliesCount = repliesCount + self.url = url + self.inReplyToID = inReplyToID + self.inReplyToAccountID = inReplyToAccountID + self.language = language + self.text = text + self.networkDate = networkDate + } - public let updatedAt: Date - public let deletedAt: Date? - - public let author: MastodonUser - - public let homeTimelineIndexes: Set? } } @@ -277,20 +232,39 @@ extension Toot: Managed { } } -public extension Toot { - static func predicate(idStr: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(Toot.id), idStr) +extension Toot { + + static func predicate(domain: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Toot.domain), domain) } - static func predicate(idStrs: [String]) -> NSPredicate { - return NSPredicate(format: "%K IN %@", #keyPath(Toot.id), idStrs) + static func predicate(id: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Toot.id), id) } - static func notDeleted() -> NSPredicate { + public static func predicate(domain: String, id: String) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + predicate(domain: domain), + predicate(id: id) + ]) + } + + static func predicate(ids: [String]) -> NSPredicate { + return NSPredicate(format: "%K IN %@", #keyPath(Toot.id), ids) + } + + public static func predicate(domain: String, ids: [String]) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + predicate(domain: domain), + predicate(ids: ids) + ]) + } + + public static func notDeleted() -> NSPredicate { return NSPredicate(format: "%K == nil", #keyPath(Toot.deletedAt)) } - static func deleted() -> NSPredicate { + public static func deleted() -> NSPredicate { return NSPredicate(format: "%K != nil", #keyPath(Toot.deletedAt)) } } diff --git a/CoreDataStack/Extension/Collection.swift b/CoreDataStack/Extension/Collection.swift index 1b2574b54..a57737d1a 100644 --- a/CoreDataStack/Extension/Collection.swift +++ b/CoreDataStack/Extension/Collection.swift @@ -3,7 +3,6 @@ // CoreDataStack // // Created by Cirno MainasuK on 2020-10-14. -// Copyright © 2020 Twidere. All rights reserved. // import Foundation diff --git a/CoreDataStack/Extension/NSManagedObjectContext.swift b/CoreDataStack/Extension/NSManagedObjectContext.swift index 159d4bd1e..e3f6600c7 100644 --- a/CoreDataStack/Extension/NSManagedObjectContext.swift +++ b/CoreDataStack/Extension/NSManagedObjectContext.swift @@ -3,7 +3,6 @@ // CoreDataStack // // Created by Cirno MainasuK on 2020-8-10. -// Copyright © 2020 Dimension. All rights reserved. // import os diff --git a/CoreDataStack/Protocol/Managed.swift b/CoreDataStack/Protocol/Managed.swift index 3d297779b..4811b9c6b 100644 --- a/CoreDataStack/Protocol/Managed.swift +++ b/CoreDataStack/Protocol/Managed.swift @@ -3,7 +3,6 @@ // CoreDataStack // // Created by Cirno MainasuK on 2020-8-6. -// Copyright © 2020 Dimension. All rights reserved. // import Foundation diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 42d9f60f1..11403cdc8 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -49,6 +49,7 @@ DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */; }; DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* CommonOSLog */; }; DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; }; + DB084B5725CBC56C00F898ED /* Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Toot.swift */; }; DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; }; DB3D100D25BAA75E00EAA174 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB3D100F25BAA75E00EAA174 /* Localizable.strings */; }; DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD525BAA00100D1B89D /* AppDelegate.swift */; }; @@ -195,6 +196,7 @@ DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift; sourceTree = ""; }; DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModel.swift; sourceTree = ""; }; DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; + DB084B5625CBC56C00F898ED /* Toot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toot.swift; sourceTree = ""; }; DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = ""; }; DB3D100E25BAA75E00EAA174 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; DB427DD225BAA00100D1B89D /* Mastodon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mastodon.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -463,6 +465,15 @@ path = PinBased; sourceTree = ""; }; + DB084B5125CBC56300F898ED /* CoreDataStack */ = { + isa = PBXGroup; + children = ( + DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */, + DB084B5625CBC56C00F898ED /* Toot.swift */, + ); + path = CoreDataStack; + sourceTree = ""; + }; DB3D0FF725BAA68500EAA174 /* Supporting Files */ = { isa = PBXGroup; children = ( @@ -553,8 +564,8 @@ DB45FB0425CA87B4005A8AC7 /* APIService */ = { isa = PBXGroup; children = ( - 2D61335625C1887F00CAE157 /* Persist */, DB45FB0925CA87BC005A8AC7 /* CoreData */, + 2D61335625C1887F00CAE157 /* Persist */, 2D61335D25C1894B00CAE157 /* APIService.swift */, DB98337E25C9452D00AD9700 /* APIService+APIError.swift */, DB98336A25C9420100AD9700 /* APIService+App.swift */, @@ -677,7 +688,7 @@ DB8AF56225C138BC002E6C99 /* Extension */ = { isa = PBXGroup; children = ( - DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */, + DB084B5125CBC56300F898ED /* CoreDataStack */, DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */, @@ -1084,6 +1095,7 @@ DB98334725C8056600AD9700 /* AuthenticationViewModel.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, 2D76319F25C1521200929FB9 /* TimelineSection.swift in Sources */, + DB084B5725CBC56C00F898ED /* Toot.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 24ba6665b..6f7ab5892 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 7 + 8 Mastodon.xcscheme_^#shared#^_ diff --git a/Mastodon/Extension/MastodonUser.swift b/Mastodon/Extension/CoreDataStack/MastodonUser.swift similarity index 100% rename from Mastodon/Extension/MastodonUser.swift rename to Mastodon/Extension/CoreDataStack/MastodonUser.swift diff --git a/Mastodon/Extension/CoreDataStack/Toot.swift b/Mastodon/Extension/CoreDataStack/Toot.swift new file mode 100644 index 000000000..f71fa949e --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/Toot.swift @@ -0,0 +1,34 @@ +// +// Toot.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/2/4. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +extension Toot.Property { + init(entity: Mastodon.Entity.Status, domain: String, networkDate: Date) { + self.init( + 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, + 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, + language: entity.language, + text: entity.text, + networkDate: networkDate + ) + } +} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index 042d15823..73c5c7835 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -76,10 +76,6 @@ extension PublicTimelineViewController { ) } - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - viewModel.stateMachine.enter(PublicTimelineViewModel.State.Loading.self) - } } // MARK: - UIScrollViewDelegate @@ -87,11 +83,9 @@ 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 { @@ -102,7 +96,6 @@ extension PublicTimelineViewController { } // MARK: - UITableViewDelegate - extension PublicTimelineViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift index 6e4e10698..a2766a00f 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift @@ -29,6 +29,7 @@ extension PublicTimelineViewModel { timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate ) items.value = [] + stateMachine.enter(PublicTimelineViewModel.State.Loading.self) } } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift index 3174015b4..04bb4ec54 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift @@ -51,8 +51,12 @@ extension PublicTimelineViewModel.State { override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } - viewModel.fetchLatest() + viewModel.context.apiService.publicTimeline(domain: activeMastodonAuthenticationBox.domain) .receive(on: DispatchQueue.main) .sink { completion in switch completion { @@ -82,6 +86,14 @@ extension PublicTimelineViewModel.State { return false } } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel else { return } + + // trigger items update + viewModel.items.value = viewModel.items.value + } } class Idle: PublicTimelineViewModel.State { @@ -110,29 +122,36 @@ extension PublicTimelineViewModel.State { 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 - + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + let maxID = viewModel.tootIDs.value.last + viewModel.context.apiService.publicTimeline( + domain: activeMastodonAuthenticationBox.domain, + maxID: maxID + ) + .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 } - .store(in: &viewModel.disposeBag) + } 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 4bc465248..fda4a2409 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift @@ -46,7 +46,7 @@ class PublicTimelineViewModel: NSObject { self.context = context self.fetchedResultsController = { let fetchRequest = Toot.sortedFetchRequest - fetchRequest.predicate = Toot.predicate(idStrs: []) + fetchRequest.predicate = Toot.predicate(domain: "", ids: []) fetchRequest.returnsObjectsAsFaults = false fetchRequest.fetchBatchSize = 20 let controller = NSFetchedResultsController( @@ -89,7 +89,8 @@ class PublicTimelineViewModel: NSObject { .receive(on: DispatchQueue.main) .sink { [weak self] ids in guard let self = self else { return } - self.fetchedResultsController.fetchRequest.predicate = Toot.predicate(idStrs: ids) + let domain = self.context.authenticationService.activeMastodonAuthenticationBox.value?.domain ?? "" + self.fetchedResultsController.fetchRequest.predicate = Toot.predicate(domain: domain, ids: ids) do { try self.fetchedResultsController.performFetch() } catch { @@ -105,9 +106,6 @@ class PublicTimelineViewModel: NSObject { } extension PublicTimelineViewModel { - func fetchLatest() -> AnyPublisher, Error> { - return context.apiService.publicTimeline(domain: "mstdn.jp") - } func loadMore() -> AnyPublisher, Error> { return context.apiService.publicTimeline(domain: "mstdn.jp") diff --git a/Mastodon/Service/APIService/APIService+HomeTimeline.swift b/Mastodon/Service/APIService/APIService+HomeTimeline.swift index 0486ebec0..b4160fad4 100644 --- a/Mastodon/Service/APIService/APIService+HomeTimeline.swift +++ b/Mastodon/Service/APIService/APIService+HomeTimeline.swift @@ -9,6 +9,7 @@ import Foundation import Combine import CoreData import CoreDataStack +import CommonOSLog import DateToolsSwift import MastodonSDK @@ -19,15 +20,17 @@ extension APIService { sinceID: Mastodon.Entity.Status.ID? = nil, maxID: Mastodon.Entity.Status.ID? = nil, limit: Int = 100, + local: Bool? = nil, authorizationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { let authorization = authorizationBox.userAuthorization + let requestMastodonUserID = authorizationBox.userID let query = Mastodon.API.Timeline.HomeTimelineQuery( maxID: maxID, sinceID: sinceID, minID: nil, // prefer sinceID limit: limit, - local: nil // TODO: + local: local ) return Mastodon.API.Timeline.home( @@ -38,10 +41,13 @@ extension APIService { ) .flatMap { response -> AnyPublisher, Error> in return APIService.Persist.persistTimeline( - domain: domain, managedObjectContext: self.backgroundManagedObjectContext, + domain: domain, + query: query, response: response, - persistType: .homeTimeline + persistType: .home, + requestMastodonUserID: requestMastodonUserID, + log: OSLog.api ) .setFailureType(to: Error.self) .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Toot]> in diff --git a/Mastodon/Service/APIService/APIService+PublicTimeline.swift b/Mastodon/Service/APIService/APIService+PublicTimeline.swift index 454bde080..aa45dbb92 100644 --- a/Mastodon/Service/APIService/APIService+PublicTimeline.swift +++ b/Mastodon/Service/APIService/APIService+PublicTimeline.swift @@ -9,6 +9,7 @@ import Foundation import Combine import CoreData import CoreDataStack +import CommonOSLog import DateToolsSwift import MastodonSDK @@ -39,10 +40,13 @@ extension APIService { ) .flatMap { response -> AnyPublisher, Error> in return APIService.Persist.persistTimeline( - domain: domain, managedObjectContext: self.backgroundManagedObjectContext, + domain: domain, + query: query, response: response, - persistType: Persist.PersistTimelineType.publicTimeline + persistType: .public, + requestMastodonUserID: nil, + log: OSLog.api ) .setFailureType(to: Error.self) .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Toot]> in diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift index b4bdb28bb..c9f5e325c 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift @@ -13,25 +13,26 @@ import MastodonSDK extension APIService.CoreData { - static func createOrMergeTweet( + static func createOrMergeToot( into managedObjectContext: NSManagedObjectContext, - for requestMastodonUser: MastodonUser, + for requestMastodonUser: MastodonUser?, entity: Mastodon.Entity.Toot, domain: String, networkDate: Date, log: OSLog - ) -> (Toot: Toot, isTweetCreated: Bool, isMastodonUserCreated: Bool) { + ) -> (Toot: Toot, isTootCreated: 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) + let (toot, _, _) = createOrMergeToot(into: managedObjectContext, for: requestMastodonUser, entity: entity, domain: domain, networkDate: networkDate, log: log) return toot } // fetch old Toot - let oldTweet: Toot? = { + let oldToot: Toot? = { let request = Toot.sortedFetchRequest - request.predicate = Toot.predicate(idStr: entity.id) + request.predicate = Toot.predicate(domain: domain, id: entity.id) + request.fetchLimit = 1 request.returnsObjectsAsFaults = false do { return try managedObjectContext.fetch(request).first @@ -41,64 +42,47 @@ extension APIService.CoreData { } }() - if let oldTweet = oldTweet { + if let oldToot = oldToot { // merge old Toot - APIService.CoreData.mergeToot(for: requestMastodonUser, old: oldTweet,in: domain, entity: entity, networkDate: networkDate) - return (oldTweet, false, false) + APIService.CoreData.mergeToot(for: requestMastodonUser, old: oldToot,in: domain, entity: entity, networkDate: networkDate) + return (oldToot, 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 + 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 + } + 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 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, + } + let tootProperty = Toot.Property(entity: entity, domain: domain, networkDate: networkDate) + let toot = Toot.insert( + into: managedObjectContext, + property: tootProperty, + author: mastodonUser, + reblog: reblog, 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) + favouritedBy: requestMastodonUser, + rebloggedBy: requestMastodonUser, + mutedBy: requestMastodonUser, + bookmarkedBy: requestMastodonUser, + pinnedBy: requestMastodonUser + ) 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 } @@ -114,8 +98,7 @@ extension APIService.CoreData { if entity.reblogsCount != toot.reblogsCount.intValue { toot.update(reblogsCount:NSNumber(value: entity.reblogsCount)) } - - + // set updateAt toot.didUpdate(at: networkDate) diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift index fd2cd1556..61ff91de0 100644 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift @@ -6,6 +6,7 @@ // import os.log +import func QuartzCore.CACurrentMediaTime import Foundation import Combine import CoreData @@ -13,71 +14,190 @@ import CoreDataStack import MastodonSDK extension APIService.Persist { + enum PersistTimelineType { - case publicTimeline - case homeTimeline + case `public` + case home } + static func persistTimeline( - domain: String, managedObjectContext: NSManagedObjectContext, + domain: String, + query: Mastodon.API.Timeline.TimelineQuery, response: Mastodon.Response.Content<[Mastodon.Entity.Toot]>, - persistType: PersistTimelineType + persistType: PersistTimelineType, + requestMastodonUserID: MastodonUser.ID?, // could be nil when response from public endpoint + log: OSLog ) -> AnyPublisher, Never> { + let toots = response.value + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: persist %{public}ld toots…", ((#file as NSString).lastPathComponent), #line, #function, toots.count) + return managedObjectContext.performChanges { - let toots = response.value - 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)) - }) - let emojis = $0.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 = $0.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: $0.id, - uri: $0.uri, - createdAt: $0.createdAt, - content: $0.content, - visibility: $0.visibility?.rawValue, - sensitive: $0.sensitive ?? false, - spoilerText: $0.spoilerText, - application: application, - mentions: metions, - emojis: emojis, - tags: tags, - reblogsCount: NSNumber(value: $0.reblogsCount), - favouritesCount: NSNumber(value: $0.favouritesCount), - repliesCount: ($0.repliesCount != nil) ? NSNumber(value: $0.repliesCount!) : nil, - url: $0.uri, - inReplyToID: $0.inReplyToID, - inReplyToAccountID: $0.inReplyToAccountID, - reblog: nil, //TODO need fix - language: $0.language, - text: $0.text, - favouritedBy: ($0.favourited ?? false) ? author : nil, - rebloggedBy: ($0.reblogged ?? false) ? author : nil, - mutedBy: ($0.muted ?? false) ? author : nil, - bookmarkedBy: ($0.bookmarked ?? false) ? author : nil, - pinnedBy: ($0.pinned ?? false) ? author : nil, - updatedAt: response.networkDate, - deletedAt: nil, - author: author, - homeTimelineIndexes: nil) - Toot.insert(into: managedObjectContext, property: tootProperty, author: author) + let contextTaskSignpostID = OSSignpostID(log: log) + let start = CACurrentMediaTime() + os_signpost(.begin, log: log, name: #function, signpostID: contextTaskSignpostID) + defer { + os_signpost(.end, log: .api, name: #function, signpostID: contextTaskSignpostID) + let end = CACurrentMediaTime() + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: persist cost %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start) } + + // load request mastodon user + let requestMastodonUser: MastodonUser? = { + guard let requestMastodonUserID = requestMastodonUserID else { return nil } + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + // load working set into context to avoid cache miss + let cacheTaskSignpostID = OSSignpostID(log: log) + os_signpost(.begin, log: log, name: "load toots into cache", signpostID: cacheTaskSignpostID) + let workingIDRecord = APIService.Persist.WorkingIDRecord.workingID(entities: toots) + + // contains toots and reblogs + let _tootCache: [Toot] = { + let request = Toot.sortedFetchRequest + let idSet = workingIDRecord.statusIDSet + .union(workingIDRecord.reblogIDSet) + let ids = Array(idSet) + request.predicate = Toot.predicate(domain: domain, ids: ids) + request.returnsObjectsAsFaults = false + request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] + do { + return try managedObjectContext.fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return [] + } + }() + os_signpost(.event, log: log, name: "load toots into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld toots", _tootCache.count) + os_signpost(.end, log: log, name: "load toots into cache", signpostID: cacheTaskSignpostID) + + // remote timeline merge local timeline record set + // declare it before do working + let mergedOldTootsInTimeline = _tootCache.filter { + return $0.homeTimelineIndexes?.contains(where: { $0.userID == requestMastodonUserID }) ?? false + } + + let updateDatabaseTaskSignpostID = OSSignpostID(log: log) + let recordType: WorkingRecord.RecordType = { + switch persistType { + case .public: return .publicTimeline + case .home: return .homeTimeline + } + }() + + var workingRecords: [WorkingRecord] = [] + os_signpost(.begin, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID) + for entity in toots { + let processEntityTaskSignpostID = OSSignpostID(log: log) + os_signpost(.begin, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id) + defer { + os_signpost(.end, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id) + } + let record = WorkingRecord.createOrMergeToot( + into: managedObjectContext, + for: requestMastodonUser, + domain: domain, + entity: entity, + recordType: recordType, + networkDate: response.networkDate, + log: log + ) + workingRecords.append(record) + } // end for… + os_signpost(.end, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID) + + // home & mention timeline tasks + switch persistType { + case .home: + // Task 1: update anchor hasMore + // update maxID anchor hasMore attribute when fetching on timeline + // do not use working records due to anchor toot is removable on the remote + var anchorToot: Toot? + if let maxID = query.maxID { + do { + // load anchor toot from database + let request = Toot.sortedFetchRequest + request.predicate = Toot.predicate(domain: domain, id: maxID) + request.returnsObjectsAsFaults = false + request.fetchLimit = 1 + anchorToot = try managedObjectContext.fetch(request).first + if persistType == .home { + let timelineIndex = anchorToot.flatMap { toot in + toot.homeTimelineIndexes?.first(where: { $0.userID == requestMastodonUserID }) + } + timelineIndex?.update(hasMore: false) + } else { + assertionFailure() + } + } catch { + assertionFailure(error.localizedDescription) + } + } + + // Task 2: set last toot hasMore when fetched toots not overlap with the timeline in the local database + let _oldestRecord = workingRecords + .sorted(by: { $0.status.createdAt < $1.status.createdAt }) + .first + if let oldestRecord = _oldestRecord { + if let anchorToot = anchorToot { + // using anchor. set hasMore when (overlap itself OR no overlap) AND oldest record NOT anchor + let isNoOverlap = mergedOldTootsInTimeline.isEmpty + let isOnlyOverlapItself = mergedOldTootsInTimeline.count == 1 && mergedOldTootsInTimeline.first?.id == anchorToot.id + let isAnchorEqualOldestRecord = oldestRecord.status.id == anchorToot.id + if (isNoOverlap || isOnlyOverlapItself) && !isAnchorEqualOldestRecord { + if persistType == .home { + let timelineIndex = oldestRecord.status.homeTimelineIndexes? + .first(where: { $0.userID == requestMastodonUserID }) + timelineIndex?.update(hasMore: true) + } else { + assertionFailure() + } + } + + } else if mergedOldTootsInTimeline.isEmpty { + // no anchor. set hasMore when no overlap + if persistType == .home { + let timelineIndex = oldestRecord.status.homeTimelineIndexes? + .first(where: { $0.userID == requestMastodonUserID }) + timelineIndex?.update(hasMore: true) + } + } + } else { + // empty working record. mark anchor hasMore in the task 1 + } + default: + break + } + + // print working record tree map + #if DEBUG + DispatchQueue.global(qos: .utility).async { + let logs = workingRecords + .map { record in record.log() } + .joined(separator: "\n") + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: working status: \n%s", ((#file as NSString).lastPathComponent), #line, #function, logs) + let counting = workingRecords + .map { record in record.count() } + .reduce(into: WorkingRecord.Counting(), { result, next in result = result + next }) + let newTootsInTimeLineCount = workingRecords.reduce(0, { result, next in + return next.statusProcessType == .create ? result + 1 : result + }) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: toot: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTootsInTimeLineCount, counting.status.create, mergedOldTootsInTimeline.count, counting.status.merge) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge) + } + #endif } + .eraseToAnyPublisher() .handleEvents(receiveOutput: { result in switch result { case .success: @@ -92,3 +212,232 @@ extension APIService.Persist { .eraseToAnyPublisher() } } + +extension APIService.Persist { + + struct WorkingIDRecord { + var statusIDSet: Set + var reblogIDSet: Set + var userIDSet: Set + + enum RecordType { + case timeline + case reblog + } + + init(statusIDSet: Set = Set(), reblogIDSet: Set = Set(), userIDSet: Set = Set()) { + self.statusIDSet = statusIDSet + self.reblogIDSet = reblogIDSet + self.userIDSet = userIDSet + } + + mutating func union(record: WorkingIDRecord) { + statusIDSet = statusIDSet.union(record.statusIDSet) + reblogIDSet = reblogIDSet.union(record.reblogIDSet) + userIDSet = userIDSet.union(record.userIDSet) + } + + static func workingID(entities: [Mastodon.Entity.Status]) -> WorkingIDRecord { + var value = WorkingIDRecord() + for entity in entities { + let child = workingID(entity: entity, recordType: .timeline) + value.union(record: child) + } + return value + } + + private static func workingID(entity: Mastodon.Entity.Status, recordType: RecordType) -> WorkingIDRecord { + var value = WorkingIDRecord() + switch recordType { + case .timeline: value.statusIDSet = Set([entity.id]) + case .reblog: value.reblogIDSet = Set([entity.id]) + } + value.userIDSet = Set([entity.account.id]) + + if let reblog = entity.reblog { + let child = workingID(entity: reblog, recordType: .reblog) + value.union(record: child) + } + return value + } + } + + class WorkingRecord { + + let status: Toot + let children: [WorkingRecord] + let recordType: RecordType + let statusProcessType: ProcessType + let userProcessType: ProcessType + + init( + status: Toot, + children: [APIService.Persist.WorkingRecord], + recordType: APIService.Persist.WorkingRecord.RecordType, + tootProcessType: ProcessType, + userProcessType: ProcessType + ) { + self.status = status + self.children = children + self.recordType = recordType + self.statusProcessType = tootProcessType + self.userProcessType = userProcessType + } + + enum RecordType { + case publicTimeline + case homeTimeline + case mentionTimeline + case userTimeline + case favoriteTimeline + case searchTimeline + + case reblog + + var flag: String { + switch self { + case .publicTimeline: return "P" + case .homeTimeline: return "H" + case .mentionTimeline: return "M" + case .userTimeline: return "U" + case .favoriteTimeline: return "F" + case .searchTimeline: return "S" + case .reblog: return "R" + } + } + } + + enum ProcessType { + case create + case merge + + var flag: String { + switch self { + case .create: return "+" + case .merge: return "-" + } + } + } + + func log(indentLevel: Int = 0) -> String { + let indent = Array(repeating: " ", count: indentLevel).joined() + let tootPreview = status.content.prefix(32).replacingOccurrences(of: "\n", with: " ") + let message = "\(indent)[\(statusProcessType.flag)\(recordType.flag)](\(status.id)) [\(userProcessType.flag)](\(status.author.id))@\(status.author.username) ~> \(tootPreview)" + + var childrenMessages: [String] = [] + for child in children { + childrenMessages.append(child.log(indentLevel: indentLevel + 1)) + } + let result = [[message] + childrenMessages] + .flatMap { $0 } + .joined(separator: "\n") + + return result + } + + struct Counting { + var status = Counter() + var user = Counter() + + static func + (left: Counting, right: Counting) -> Counting { + return Counting( + status: left.status + right.status, + user: left.user + right.user + ) + } + + struct Counter { + var create = 0 + var merge = 0 + + static func + (left: Counter, right: Counter) -> Counter { + return Counter( + create: left.create + right.create, + merge: left.merge + right.merge + ) + } + } + } + + func count() -> Counting { + var counting = Counting() + + switch statusProcessType { + case .create: counting.status.create += 1 + case .merge: counting.status.merge += 1 + } + + switch userProcessType { + case .create: counting.user.create += 1 + case .merge: counting.user.merge += 1 + } + + for child in children { + let childCounting = child.count() + counting = counting + childCounting + } + + return counting + } + + // handle timelineIndex insert with APIService.Persist.createOrMergeToot + static func createOrMergeToot( + into managedObjectContext: NSManagedObjectContext, + for requestMastodonUser: MastodonUser?, + domain: String, + entity: Mastodon.Entity.Status, + recordType: RecordType, + networkDate: Date, + log: OSLog + ) -> WorkingRecord { + let processEntityTaskSignpostID = OSSignpostID(log: log) + os_signpost(.begin, log: log, name: "update database - process entity: createorMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id) + defer { + os_signpost(.end, log: log, name: "update database - process entity: createorMergeToot", signpostID: processEntityTaskSignpostID, "finish process toot %{public}s", entity.id) + } + + // build tree + let reblogRecord: WorkingRecord? = entity.reblog.flatMap { entity -> WorkingRecord in + createOrMergeToot(into: managedObjectContext, for: requestMastodonUser, domain: domain, entity: entity, recordType: .reblog, networkDate: networkDate, log: log) + } + let children = [reblogRecord].compactMap { $0 } + + let (status, isTootCreated, isTootUserCreated) = APIService.CoreData.createOrMergeToot(into: managedObjectContext, for: requestMastodonUser, entity: entity, domain: domain, networkDate: networkDate, log: log) + + let result = WorkingRecord( + status: status, + children: children, + recordType: recordType, + tootProcessType: isTootCreated ? .create : .merge, + userProcessType: isTootUserCreated ? .create : .merge + ) + + switch (result.statusProcessType, recordType) { + case (.create, .homeTimeline), (.merge, .homeTimeline): + guard let requestMastodonUserID = requestMastodonUser?.id else { + assertionFailure("Request user is required for home timeline") + break + } + let timelineIndex = status.homeTimelineIndexes? + .first { $0.userID == requestMastodonUserID } + if timelineIndex == nil { + let timelineIndexProperty = HomeTimelineIndex.Property(domain: domain) + let _ = HomeTimelineIndex.insert( + into: managedObjectContext, + property: timelineIndexProperty, + toot: status + ) + } else { + // enity already in home timeline + } + default: + break + } + + return result + } + + } + +} + diff --git a/Mastodon/Service/AuthenticationService.swift b/Mastodon/Service/AuthenticationService.swift index 16969b8aa..9fa411f22 100644 --- a/Mastodon/Service/AuthenticationService.swift +++ b/Mastodon/Service/AuthenticationService.swift @@ -62,6 +62,7 @@ class AuthenticationService: NSObject { .map { authentication -> AuthenticationService.MastodonAuthenticationBox? in guard let authentication = authentication else { return nil } return AuthenticationService.MastodonAuthenticationBox( + domain: authentication.domain, userID: authentication.userID, appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken) @@ -82,6 +83,7 @@ class AuthenticationService: NSObject { extension AuthenticationService { struct MastodonAuthenticationBox { + let domain: String let userID: MastodonUser.ID let appAuthorization: Mastodon.API.OAuth.Authorization let userAuthorization: Mastodon.API.OAuth.Authorization diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift index 2ee3e8e85..66d7d8060 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift @@ -56,9 +56,16 @@ extension Mastodon.API.Timeline { } +public protocol TimelineQueryType { + var maxID: Mastodon.Entity.Toot.ID? { get } + var sinceID: Mastodon.Entity.Toot.ID? { get } +} + extension Mastodon.API.Timeline { - public struct PublicTimelineQuery: Codable, GetQuery { + public typealias TimelineQuery = TimelineQueryType + + public struct PublicTimelineQuery: Codable, TimelineQuery, GetQuery { public let local: Bool? public let remote: Bool? @@ -100,7 +107,7 @@ extension Mastodon.API.Timeline { } } - public struct HomeTimelineQuery: Codable, GetQuery { + public struct HomeTimelineQuery: Codable, TimelineQuery, GetQuery { public let maxID: Mastodon.Entity.Toot.ID? public let sinceID: Mastodon.Entity.Toot.ID? public let minID: Mastodon.Entity.Toot.ID?