diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index e8d8dd60..c0e864dc 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 7c4e4eb7..93fa0fb5 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 8ac22c0b..bcbfe5d2 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 c43d0d6c..6dab7010 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 1b2574b5..a57737d1 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 159d4bd1..e3f6600c 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 3d297779..4811b9c6 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 e67aded3..11403cdc 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -39,18 +39,17 @@ 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 */; }; 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; 5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; }; 5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; }; - 7A9135D4559750AF07CA9BE4 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 602D783BEC22881EBAD84419 /* Pods_Mastodon.framework */; }; DB01409625C40B6700F9F3CF /* AuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB01409525C40B6700F9F3CF /* AuthenticationViewController.swift */; }; DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */; }; DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */; }; 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 */; }; @@ -67,6 +66,7 @@ DB45FAED25CA7A9A005A8AC7 /* MastodonAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */; }; DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */; }; DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */; }; + DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */; }; DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; DB89B9FE25C10FD0008580ED /* CoreDataStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89B9FD25C10FD0008580ED /* CoreDataStackTests.swift */; }; DB89BA0025C10FD0008580ED /* CoreDataStack.h in Headers */ = {isa = PBXBuildFile; fileRef = DB89B9F025C10FD0008580ED /* CoreDataStack.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -186,10 +186,8 @@ 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = ""; }; - 602D783BEC22881EBAD84419 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release.xcconfig"; sourceTree = ""; }; 9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release.xcconfig"; sourceTree = ""; }; - A1B4523A7981F1044DE89C21 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BB482D32A7B9825BF5327C4F /* Pods-Mastodon-MastodonUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.release.xcconfig"; sourceTree = ""; }; CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -198,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; }; @@ -220,6 +219,7 @@ DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthentication.swift; sourceTree = ""; }; DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonAuthentication.swift"; sourceTree = ""; }; DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; + DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HomeTimeline.swift"; sourceTree = ""; }; DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DB89B9F025C10FD0008580ED /* CoreDataStack.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CoreDataStack.h; sourceTree = ""; }; DB89B9F125C10FD0008580ED /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -267,7 +267,6 @@ 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */, 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, - 7A9135D4559750AF07CA9BE4 /* Pods_Mastodon.framework in Frameworks */, DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */, 45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */, ); @@ -286,7 +285,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 3533495136D843E85211E3E2 /* Pods_Mastodon_MastodonUITests.framework in Frameworks */, 18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -422,8 +420,6 @@ 2D7631A625C1533800929FB9 /* TableviewCell */ = { isa = PBXGroup; children = ( - 602D783BEC22881EBAD84419 /* Pods_Mastodon.framework */, - A1B4523A7981F1044DE89C21 /* Pods_Mastodon_MastodonUITests.framework */, 2D7631A725C1535600929FB9 /* TimelinePostTableViewCell.swift */, 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */, 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */, @@ -469,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 = ( @@ -559,14 +564,15 @@ DB45FB0425CA87B4005A8AC7 /* APIService */ = { isa = PBXGroup; children = ( - 2D61335625C1887F00CAE157 /* Persist */, DB45FB0925CA87BC005A8AC7 /* CoreData */, + 2D61335625C1887F00CAE157 /* Persist */, 2D61335D25C1894B00CAE157 /* APIService.swift */, DB98337E25C9452D00AD9700 /* APIService+APIError.swift */, DB98336A25C9420100AD9700 /* APIService+App.swift */, DB98337025C9443200AD9700 /* APIService+Authentication.swift */, DB98339B25C96DE600AD9700 /* APIService+Account.swift */, 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */, + DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */, ); path = APIService; sourceTree = ""; @@ -682,7 +688,7 @@ DB8AF56225C138BC002E6C99 /* Extension */ = { isa = PBXGroup; children = ( - DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */, + DB084B5125CBC56300F898ED /* CoreDataStack */, DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */, @@ -1060,6 +1066,7 @@ buildActionMask = 2147483647; files = ( DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */, + DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */, 2D7631B325C159F700929FB9 /* Item.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, @@ -1088,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 997b42f4..6f7ab589 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,12 +7,12 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 7 + 8 Mastodon.xcscheme_^#shared#^_ orderHint - 0 + 1 SuppressBuildableAutocreation 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 00000000..2fab537e --- /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.flatMap { NSNumber(value: $0) }, + 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 042d1582..73c5c783 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 6e4e1069..a2766a00 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 3174015b..04bb4ec5 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 115a35f2..fda4a240 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,11 +106,8 @@ class PublicTimelineViewModel: NSObject { } 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") + return context.apiService.publicTimeline(domain: "mstdn.jp") } } diff --git a/Mastodon/Service/APIService/APIService+HomeTimeline.swift b/Mastodon/Service/APIService/APIService+HomeTimeline.swift new file mode 100644 index 00000000..b4160fad --- /dev/null +++ b/Mastodon/Service/APIService/APIService+HomeTimeline.swift @@ -0,0 +1,66 @@ +// +// APIService+HomeTimeline.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/2/3. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import DateToolsSwift +import MastodonSDK + +extension APIService { + + func homeTimeline( + domain: String, + 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: local + ) + + return Mastodon.API.Timeline.home( + session: session, + domain: domain, + query: query, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + return APIService.Persist.persistTimeline( + managedObjectContext: self.backgroundManagedObjectContext, + domain: domain, + query: query, + response: response, + persistType: .home, + requestMastodonUserID: requestMastodonUserID, + log: OSLog.api + ) + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Toot]> in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/APIService+PublicTimeline.swift b/Mastodon/Service/APIService/APIService+PublicTimeline.swift index 3e75d246..aa45dbb9 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 @@ -17,21 +18,35 @@ extension APIService { static let publicTimelineRequestWindowInSec: TimeInterval = 15 * 60 func publicTimeline( - count: Int = 20, - domain: String + domain: String, + sinceID: Mastodon.Entity.Status.ID? = nil, + maxID: Mastodon.Entity.Status.ID? = nil, + limit: Int = 100 ) -> AnyPublisher, Error> { - + let query = Mastodon.API.Timeline.PublicTimelineQuery( + local: nil, + remote: nil, + onlyMedia: nil, + maxID: maxID, + sinceID: sinceID, + minID: nil, // prefer sinceID + limit: limit + ) + return Mastodon.API.Timeline.public( session: session, domain: domain, - query: Mastodon.API.Timeline.PublicTimelineQuery() + query: query ) .flatMap { response -> AnyPublisher, Error> in return APIService.Persist.persistTimeline( - domain: domain, managedObjectContext: self.backgroundManagedObjectContext, + domain: domain, + query: query, response: response, - persistType: Persist.PersistTimelineType.publicHomeTimeline + 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 b4bdb28b..c9f5e325 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 ec38e599..61ff91de 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,70 +14,190 @@ import CoreDataStack import MastodonSDK extension APIService.Persist { + enum PersistTimelineType { - case publicHomeTimeline + 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: @@ -91,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 16969b8a..9fa411f2 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 95f912b4..66d7d806 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift @@ -13,6 +13,9 @@ extension Mastodon.API.Timeline { static func publicTimelineEndpointURL(domain: String) -> URL { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("timelines/public") } + static func homeTimelineEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("timelines/home") + } public static func `public`( session: URLSession, @@ -32,10 +35,37 @@ extension Mastodon.API.Timeline { .eraseToAnyPublisher() } + public static func home( + session: URLSession, + domain: String, + query: HomeTimelineQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: homeTimelineEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Toot].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +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? @@ -76,4 +106,38 @@ extension Mastodon.API.Timeline { return items } } + + 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? + public let limit: Int? + public let local: Bool? + + public init( + maxID: Mastodon.Entity.Toot.ID? = nil, + sinceID: Mastodon.Entity.Toot.ID? = nil, + minID: Mastodon.Entity.Toot.ID? = nil, + limit: Int? = nil, + local: Bool? = nil + ) { + self.maxID = maxID + self.sinceID = sinceID + self.minID = minID + self.limit = limit + self.local = local + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } + sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) } + minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + local.flatMap { items.append(URLQueryItem(name: "local", value: $0.queryItemValue)) } + guard !items.isEmpty else { return nil } + return items + } + } + } diff --git a/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+TimelineTests.swift b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+TimelineTests.swift new file mode 100644 index 00000000..ec6cb849 --- /dev/null +++ b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+TimelineTests.swift @@ -0,0 +1,71 @@ +// +// MastodonSDK+API+TimelineTests.swift +// +// +// Created by MainasuK Cirno on 2021/2/3. +// + +import os.log +import XCTest +import Combine +@testable import MastodonSDK + +extension MastodonSDKTests { + + func testPublicTimeline() throws { + try _testPublicTimeline(domain: domain) + } + + private func _testPublicTimeline(domain: String) throws { + let theExpectation = expectation(description: "Fetch Public Timeline") + + let query = Mastodon.API.Timeline.PublicTimelineQuery() + Mastodon.API.Timeline.public(session: session, domain: domain, query: query) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + XCTFail(error.localizedDescription) + case .finished: + break + } + } receiveValue: { response in + XCTAssert(!response.value.isEmpty) + theExpectation.fulfill() + } + .store(in: &disposeBag) + + wait(for: [theExpectation], timeout: 10.0) + } + +} + +extension MastodonSDKTests { + + func testHomeTimeline() { + let domain = "" + let accessToken = "" + guard !domain.isEmpty, !accessToken.isEmpty else { return } + + let query = Mastodon.API.Timeline.HomeTimelineQuery() + let authorization = Mastodon.API.OAuth.Authorization(accessToken: accessToken) + let theExpectation = expectation(description: "Fetch Home Timeline") + Mastodon.API.Timeline.home(session: session, domain: domain, query: query, authorization: authorization) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + XCTFail(error.localizedDescription) + case .finished: + break + } + } receiveValue: { response in + XCTAssert(!response.value.isEmpty) + theExpectation.fulfill() + } + .store(in: &disposeBag) + + wait(for: [theExpectation], timeout: 10.0) + } + +} diff --git a/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift b/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift index 8996d1aa..b32261c3 100644 --- a/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift +++ b/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift @@ -14,33 +14,3 @@ final class MastodonSDKTests: XCTestCase { } } - -extension MastodonSDKTests { - - func testPublicTimeline() throws { - try _testPublicTimeline(domain: domain) - } - - private func _testPublicTimeline(domain: String) throws { - let theExpectation = expectation(description: "Fetch Public Timeline") - - let query = Mastodon.API.Timeline.PublicTimelineQuery() - Mastodon.API.Timeline.public(session: session, domain: domain, query: query) - .receive(on: DispatchQueue.main) - .sink { completion in - switch completion { - case .failure(let error): - XCTFail(error.localizedDescription) - case .finished: - break - } - } receiveValue: { response in - XCTAssert(!response.value.isEmpty) - theExpectation.fulfill() - } - .store(in: &disposeBag) - - wait(for: [theExpectation], timeout: 10.0) - } - -}