diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index be40ac57d..c9ebac45f 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -159,6 +159,8 @@ + + @@ -173,6 +175,6 @@ - + \ No newline at end of file diff --git a/CoreDataStack/Entity/Toot.swift b/CoreDataStack/Entity/Toot.swift index 56d112d74..43e728283 100644 --- a/CoreDataStack/Entity/Toot.swift +++ b/CoreDataStack/Entity/Toot.swift @@ -39,6 +39,7 @@ public final class Toot: NSManagedObject { // many-to-one relastionship @NSManaged public private(set) var author: MastodonUser @NSManaged public private(set) var reblog: Toot? + @NSManaged public private(set) var replyTo: Toot? // many-to-many relastionship @NSManaged public private(set) var favouritedBy: Set? @@ -57,6 +58,7 @@ public final class Toot: NSManagedObject { @NSManaged public private(set) var tags: Set? @NSManaged public private(set) var homeTimelineIndexes: Set? @NSManaged public private(set) var mediaAttachments: Set? + @NSManaged public private(set) var replyFrom: Set? @NSManaged public private(set) var updatedAt: Date @NSManaged public private(set) var deletedAt: Date? @@ -70,6 +72,7 @@ public extension Toot { author: MastodonUser, reblog: Toot?, application: Application?, + replyTo: Toot?, poll: Poll?, mentions: [Mention]?, emojis: [Emoji]?, @@ -142,16 +145,19 @@ public extension Toot { return toot } + func update(reblogsCount: NSNumber) { if self.reblogsCount.intValue != reblogsCount.intValue { self.reblogsCount = reblogsCount } } + func update(favouritesCount: NSNumber) { if self.favouritesCount.intValue != favouritesCount.intValue { self.favouritesCount = favouritesCount } } + func update(repliesCount: NSNumber?) { guard let count = repliesCount else { return @@ -160,6 +166,13 @@ public extension Toot { self.repliesCount = repliesCount } } + + func update(replyTo: Toot?) { + if self.replyTo != replyTo { + self.replyTo = replyTo + } + } + func update(liked: Bool, mastodonUser: MastodonUser) { if liked { if !(self.favouritedBy ?? Set()).contains(mastodonUser) { @@ -171,6 +184,7 @@ public extension Toot { } } } + func update(reblogged: Bool, mastodonUser: MastodonUser) { if reblogged { if !(self.rebloggedBy ?? Set()).contains(mastodonUser) { diff --git a/Localization/app.json b/Localization/app.json index 45c771235..535630384 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -42,6 +42,7 @@ }, "status": { "user_reblogged": "%s reblogged", + "user_replied_to": "Replied to %s", "show_post": "Show Post", "status_content_warning": "content warning", "media_content_warning": "Tap to reveal that may be sensitive", @@ -49,11 +50,11 @@ "vote": "Vote", "vote_count": { "single": "%d vote", - "multiple": "%d votes", + "multiple": "%d votes" }, "voter_count": { "single": "%d voter", - "multiple": "%d voters", + "multiple": "%d voters" }, "time_left": "%s left", "closed": "Closed" diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 26e99547e..9dac11620 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -57,13 +57,13 @@ 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */; }; 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */; }; 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */; }; - 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */; }; + 2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */; }; 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; }; 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; }; 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */; }; 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */; }; 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; }; - 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */; }; + 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Status.swift */; }; 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; }; 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */; }; 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */; }; @@ -173,6 +173,11 @@ DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; }; DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; }; + DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; }; + DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */; }; + DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */; }; + DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */; }; + DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */; }; DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; }; DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; }; DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; }; @@ -328,12 +333,12 @@ 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+Diffable.swift"; sourceTree = ""; }; 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewContainer.swift; sourceTree = ""; }; 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+DebugAction.swift"; sourceTree = ""; }; - 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = ""; }; + 2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Status.swift"; sourceTree = ""; }; 2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = ""; }; 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningOverlayView.swift; sourceTree = ""; }; 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; - 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Toot.swift"; sourceTree = ""; }; + 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Status.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Status.swift"; sourceTree = ""; }; 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = ""; }; 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewModel.swift; sourceTree = ""; }; 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewController+StatusProvider.swift"; sourceTree = ""; }; @@ -452,6 +457,11 @@ DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = ""; }; + DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = ""; }; + DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = ""; }; + DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDataSourcePrefetching.swift"; sourceTree = ""; }; + DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPrefetchingService.swift; sourceTree = ""; }; + DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = ""; }; DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; }; DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = ""; }; @@ -673,6 +683,7 @@ 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */, 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */, DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */, + DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */, ); path = StatusProvider; sourceTree = ""; @@ -725,6 +736,7 @@ 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */, 2DA6054625F716A2006356F9 /* PlaybackState.swift */, 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */, + DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */, DB49A61925FF327D00B98345 /* EmojiService */, ); path = Service; @@ -733,7 +745,9 @@ 2D61335625C1887F00CAE157 /* Persist */ = { isa = PBXGroup; children = ( - 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */, + 2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */, + DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */, + DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */, ); path = Persist; sourceTree = ""; @@ -1016,6 +1030,7 @@ DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */, DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */, DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */, + DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */, DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */, ); path = APIService; @@ -1024,7 +1039,7 @@ DB45FB0925CA87BC005A8AC7 /* CoreData */ = { isa = PBXGroup; children = ( - 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */, + 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Status.swift */, DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */, DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */, ); @@ -1720,6 +1735,7 @@ DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */, 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, 0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */, + DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */, 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */, DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */, @@ -1737,6 +1753,7 @@ 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */, DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */, DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, + DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */, 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */, 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, @@ -1745,11 +1762,12 @@ 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */, DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */, DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, - 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */, + 2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, - 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */, + 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */, DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */, + DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */, DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, @@ -1779,6 +1797,7 @@ 2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */, DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, + DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, @@ -1833,6 +1852,7 @@ DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */, + DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */, DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 21afdd4cd..c57f2d22e 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -51,8 +51,8 @@ "repositoryURL": "https://github.com/onevcat/Kingfisher.git", "state": { "branch": null, - "revision": "81dd1ce8401137637663046c7314e7c885bcc56d", - "version": "6.1.1" + "revision": "daebf8ddf974164d1b9a050c8231e263f3106b09", + "version": "6.1.0" } }, { diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index bc95e2a23..809e3b3cc 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -95,12 +95,17 @@ extension StatusSection { statusItemAttribute: Item.StatusAttribute ) { // set header - cell.statusView.headerContainerStackView.isHidden = toot.reblog == nil - cell.statusView.headerInfoLabel.text = { - let author = toot.author - let name = author.displayName.isEmpty ? author.username : author.displayName - return L10n.Common.Controls.Status.userReblogged(name) - }() + StatusSection.configureHeader(cell: cell, toot: toot) + ManagedObjectObserver.observe(object: toot) + .receive(on: DispatchQueue.main) + .sink { _ in + // do nothing + } receiveValue: { change in + guard case .update(let object) = change.changeType, + let newToot = object as? Toot else { return } + StatusSection.configureHeader(cell: cell, toot: newToot) + } + .store(in: &cell.disposeBag) // set name username cell.statusView.nameLabel.text = { @@ -299,6 +304,31 @@ extension StatusSection { } .store(in: &cell.disposeBag) } + + static func configureHeader( + cell: StatusTableViewCell, + toot: Toot + ) { + if toot.reblog != nil { + cell.statusView.headerContainerStackView.isHidden = false + cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.boostIconImage) + cell.statusView.headerInfoLabel.text = { + let author = toot.author + let name = author.displayName.isEmpty ? author.username : author.displayName + return L10n.Common.Controls.Status.userReblogged(name) + }() + } else if let replyTo = toot.replyTo { + cell.statusView.headerContainerStackView.isHidden = false + cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) + cell.statusView.headerInfoLabel.text = { + let author = replyTo.author + let name = author.displayName.isEmpty ? author.username : author.displayName + return L10n.Common.Controls.Status.userRepliedTo(name) + }() + } else { + cell.statusView.headerContainerStackView.isHidden = true + } + } static func configureActionToolBar( cell: StatusTableViewCell, diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 7079b2970..f4d3a8a56 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -88,6 +88,10 @@ internal enum L10n { internal static func userReblogged(_ p1: Any) -> String { return L10n.tr("Localizable", "Common.Controls.Status.UserReblogged", String(describing: p1)) } + /// Replied to %@ + internal static func userRepliedTo(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.UserRepliedTo", String(describing: p1)) + } internal enum Poll { /// Closed internal static let closed = L10n.tr("Localizable", "Common.Controls.Status.Poll.Closed") diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index 7650471fd..f3d31ff33 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -132,7 +132,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { guard let diffableDataSource = cell.statusView.pollTableViewDataSource else { return } let item = diffableDataSource.itemIdentifier(for: indexPath) - guard case let .opion(objectID, attribute) = item else { return } + guard case let .opion(objectID, _) = item else { return } guard let option = managedObjectContext.object(with: objectID) as? PollOption else { return } let poll = option.poll diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift new file mode 100644 index 000000000..20f8e30a9 --- /dev/null +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift @@ -0,0 +1,50 @@ +// +// StatusProvider+UITableViewDataSourcePrefetching.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import UIKit +import CoreData +import CoreDataStack + +extension StatusTableViewCellDelegate where Self: StatusProvider { + func handleTableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + // prefetch reply toot + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + let domain = activeMastodonAuthenticationBox.domain + + var statusObjectIDs: [NSManagedObjectID] = [] + for item in items(indexPaths: indexPaths) { + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex + statusObjectIDs.append(homeTimelineIndex.toot.objectID) + case .toot(let objectID, _): + statusObjectIDs.append(objectID) + default: + continue + } + } + + let backgroundManagedObjectContext = context.backgroundManagedObjectContext + backgroundManagedObjectContext.perform { [weak self] in + guard let self = self else { return } + for objectID in statusObjectIDs { + let toot = backgroundManagedObjectContext.object(with: objectID) as! Toot + guard let replyToID = toot.inReplyToID, toot.replyTo == nil else { + // skip + continue + } + self.context.statusPrefetchingService.prefetchReplyTo( + domain: domain, + statusObjectID: toot.objectID, + statusID: toot.id, + replyToStatusID: replyToID, + authorizationBox: activeMastodonAuthenticationBox + ) + } + } + } +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider.swift b/Mastodon/Protocol/StatusProvider/StatusProvider.swift index a0a7116fc..4ec0d1e16 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider.swift @@ -20,4 +20,5 @@ protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewControl var managedObjectContext: NSManagedObjectContext { get } var tableViewDiffableDataSource: UITableViewDiffableDataSource? { get } func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? + func items(indexPaths: [IndexPath]) -> [Item] } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 92e0161a9..e051aa9ec 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -34,6 +34,7 @@ "Common.Controls.Status.ShowPost" = "Show Post"; "Common.Controls.Status.StatusContentWarning" = "content warning"; "Common.Controls.Status.UserReblogged" = "%@ reblogged"; +"Common.Controls.Status.UserRepliedTo" = "Replied to %@"; "Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts"; "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts..."; "Common.Countable.Photo.Multiple" = "photos"; diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 2b6694b86..6f289b339 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -45,34 +45,30 @@ extension HomeTimelineViewController { guard let self = self else { return } self.moveToTopGapAction(action) }), - UIAction(title: "First Reblog Toot", image: nil, attributes: [], handler: { [weak self] action in + UIAction(title: "First Replied Status", image: nil, attributes: [], handler: { [weak self] action in guard let self = self else { return } - self.moveToFirstReblogToot(action) + self.moveToFirstRepliedStatus(action) }), - UIAction(title: "First Poll Toot", image: nil, attributes: [], handler: { [weak self] action in + UIAction(title: "First Reblog Status", image: nil, attributes: [], handler: { [weak self] action in guard let self = self else { return } - self.moveToFirstPollToot(action) + self.moveToFirstReblogStatus(action) }), - UIAction(title: "First Audio Toot", image: nil, attributes: [], handler: { [weak self] action in + UIAction(title: "First Poll Status", image: nil, attributes: [], handler: { [weak self] action in guard let self = self else { return } - self.moveToFirstAudioToot(action) + self.moveToFirstPollStatus(action) + }), + UIAction(title: "First Audio Status", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstAudioStatus(action) + }), + UIAction(title: "First Video Status", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstVideoStatus(action) + }), + UIAction(title: "First GIF Toot", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstGIFStatus(action) }), -// UIAction(title: "First Reply Toot", image: nil, attributes: [], handler: { [weak self] action in -// guard let self = self else { return } -// self.moveToFirstReplyToot(action) -// }), -// UIAction(title: "First Reply Reblog", image: nil, attributes: [], handler: { [weak self] action in -// guard let self = self else { return } -// self.moveToFirstReplyReblog(action) -// }), -// UIAction(title: "First Video Toot", image: nil, attributes: [], handler: { [weak self] action in -// guard let self = self else { return } -// self.moveToFirstVideoToot(action) -// }), -// UIAction(title: "First GIF Toot", image: nil, attributes: [], handler: { [weak self] action in -// guard let self = self else { return } -// self.moveToFirstGIFToot(action) -// }), ] ) } @@ -109,7 +105,7 @@ extension HomeTimelineViewController { } } - @objc private func moveToFirstReblogToot(_ sender: UIAction) { + @objc private func moveToFirstReblogStatus(_ sender: UIAction) { guard let diffableDataSource = viewModel.diffableDataSource else { return } let snapshotTransitioning = diffableDataSource.snapshot() let item = snapshotTransitioning.itemIdentifiers.first(where: { item in @@ -125,11 +121,11 @@ extension HomeTimelineViewController { tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) tableView.blinkRow(at: IndexPath(row: index, section: 0)) } else { - print("Not found reblog toot") + print("Not found reblog status") } } - @objc private func moveToFirstPollToot(_ sender: UIAction) { + @objc private func moveToFirstPollStatus(_ sender: UIAction) { guard let diffableDataSource = viewModel.diffableDataSource else { return } let snapshotTransitioning = diffableDataSource.snapshot() let item = snapshotTransitioning.itemIdentifiers.first(where: { item in @@ -150,7 +146,30 @@ extension HomeTimelineViewController { } } - @objc private func moveToFirstAudioToot(_ sender: UIAction) { + @objc private func moveToFirstRepliedStatus(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + guard homeTimelineIndex.toot.inReplyToID != nil else { + return false + } + return true + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found replied status") + } + } + + @objc private func moveToFirstAudioStatus(_ sender: UIAction) { guard let diffableDataSource = viewModel.diffableDataSource else { return } let snapshotTransitioning = diffableDataSource.snapshot() let item = snapshotTransitioning.itemIdentifiers.first(where: { item in @@ -171,6 +190,48 @@ extension HomeTimelineViewController { } } + @objc private func moveToFirstVideoStatus(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + let toot = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot + return toot.mediaAttachments?.contains(where: { $0.type == .video }) ?? false + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found video status") + } + } + + @objc private func moveToFirstGIFStatus(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + let toot = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot + return toot.mediaAttachments?.contains(where: { $0.type == .gifv }) ?? false + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found GIF status") + } + } + @objc private func dropRecentStatusAction(_ sender: UIAction, count: Int) { guard let diffableDataSource = viewModel.diffableDataSource else { return } let snapshotTransitioning = diffableDataSource.snapshot() diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift index a0d9204ba..dc8eb4803 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift @@ -70,4 +70,18 @@ extension HomeTimelineViewController: StatusProvider { return item } + func items(indexPaths: [IndexPath]) -> [Item] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return [] + } + + var items: [Item] = [] + for indexPath in indexPaths { + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } + items.append(item) + } + return items + } + } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index a18410536..6efdb76a3 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -100,6 +100,7 @@ extension HomeTimelineViewController { viewModel.viewController = self viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self tableView.delegate = self + tableView.prefetchDataSource = self viewModel.setupDiffableDataSource( for: tableView, dependency: self, @@ -242,6 +243,13 @@ extension HomeTimelineViewController: UITableViewDelegate { } } +// MARK: - UITableViewDataSourcePrefetching +extension HomeTimelineViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + handleTableView(tableView, prefetchRowsAt: indexPaths) + } +} + // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate extension HomeTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { func navigationBar() -> UINavigationBar? { diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index f3e811b9f..37e8b0dab 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -375,7 +375,7 @@ extension MastodonPickServerViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { guard let diffableDataSource = viewModel.diffableDataSource else { return nil } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil } - guard case let .server(server) = item else { return nil } + guard case .server = item else { return nil } if tableView.indexPathForSelectedRow == indexPath { tableView.deselectRow(at: indexPath, animated: false) diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift index b9cdcc7e4..69f6a82fb 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift @@ -45,7 +45,7 @@ extension MastodonPickServerViewModel.LoadIndexedServerState { viewModel.context.apiService.servers(language: nil, category: nil) .sink { completion in switch completion { - case .failure(let error): + case .failure: // TODO: handle error stateMachine.enter(Fail.self) case .finished: @@ -84,7 +84,7 @@ extension MastodonPickServerViewModel.LoadIndexedServerState { override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) - guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return } + guard let viewModel = self.viewModel else { return } viewModel.isLoadingIndexedServers.value = false } } diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift index e136e86ce..09b6c327c 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift @@ -176,7 +176,7 @@ class MastodonPickServerViewModel: NSObject { switch result { case .success(let response): self.unindexedServers.send(response.value) - case .failure(let error): + case .failure: // TODO: What should be presented when user inputs invalid search text? self.unindexedServers.send([]) } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift index aceb83718..7d52a5764 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift @@ -70,4 +70,18 @@ extension PublicTimelineViewController: StatusProvider { return item } + func items(indexPaths: [IndexPath]) -> [Item] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return [] + } + + var items: [Item] = [] + for indexPath in indexPaths { + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } + items.append(item) + } + return items + } + } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index 7b3732dcd..8e954c053 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -73,6 +73,7 @@ extension PublicTimelineViewController { viewModel.tableView = tableView viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self tableView.delegate = self + tableView.prefetchDataSource = self viewModel.setupDiffableDataSource( for: tableView, dependency: self, @@ -133,6 +134,13 @@ extension PublicTimelineViewController: UITableViewDelegate { } } +// MARK: - UITableViewDataSourcePrefetching +extension PublicTimelineViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + handleTableView(tableView, prefetchRowsAt: indexPaths) + } +} + // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate extension PublicTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { func navigationBar() -> UINavigationBar? { diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 3987aa5fc..9b3db273a 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -25,6 +25,29 @@ final class StatusView: UIView { static let avatarImageCornerRadius: CGFloat = 4 static let contentWarningBlurRadius: CGFloat = 12 + static let boostIconImage: UIImage = { + let font = UIFont.systemFont(ofSize: 13, weight: .medium) + let configuration = UIImage.SymbolConfiguration(font: font) + let image = UIImage(systemName: "arrow.2.squarepath", withConfiguration: configuration)!.withTintColor(Asset.Colors.Label.secondary.color) + return image + }() + + static let replyIconImage: UIImage = { + let font = UIFont.systemFont(ofSize: 13, weight: .medium) + let configuration = UIImage.SymbolConfiguration(font: font) + let image = UIImage(systemName: "arrowshape.turn.up.left.fill", withConfiguration: configuration)!.withTintColor(Asset.Colors.Label.secondary.color) + return image + }() + + static func iconAttributedString(image: UIImage) -> NSAttributedString { + let attributedString = NSMutableAttributedString() + let imageTextAttachment = NSTextAttachment() + let imageAttribute = NSAttributedString(attachment: imageTextAttachment) + imageTextAttachment.image = image + attributedString.append(imageAttribute) + return attributedString + } + weak var delegate: StatusViewDelegate? var isStatusTextSensitive = false var pollTableViewDataSource: UITableViewDiffableDataSource? @@ -34,14 +57,7 @@ final class StatusView: UIView { let headerIconLabel: UILabel = { let label = UILabel() - let attributedString = NSMutableAttributedString() - let imageTextAttachment = NSTextAttachment() - let font = UIFont.systemFont(ofSize: 13, weight: .medium) - let configuration = UIImage.SymbolConfiguration(font: font) - imageTextAttachment.image = UIImage(systemName: "arrow.2.squarepath", withConfiguration: configuration)?.withTintColor(Asset.Colors.Label.secondary.color) - let imageAttribute = NSAttributedString(attachment: imageTextAttachment) - attributedString.append(imageAttribute) - label.attributedText = attributedString + label.attributedText = StatusView.iconAttributedString(image: StatusView.boostIconImage) return label }() diff --git a/Mastodon/Service/APIService/APIService+Account.swift b/Mastodon/Service/APIService/APIService+Account.swift index 2218bfa50..d8ea5cf4f 100644 --- a/Mastodon/Service/APIService/APIService+Account.swift +++ b/Mastodon/Service/APIService/APIService+Account.swift @@ -31,6 +31,7 @@ extension APIService { for: nil, in: domain, entity: account, + userCache: nil, networkDate: response.networkDate, log: log) let flag = isCreated ? "+" : "-" @@ -64,6 +65,7 @@ extension APIService { for: nil, in: domain, entity: account, + userCache: nil, networkDate: response.networkDate, log: log) let flag = isCreated ? "+" : "-" diff --git a/Mastodon/Service/APIService/APIService+Favorite.swift b/Mastodon/Service/APIService/APIService+Favorite.swift index 2577b6cb0..2eacff573 100644 --- a/Mastodon/Service/APIService/APIService+Favorite.swift +++ b/Mastodon/Service/APIService/APIService+Favorite.swift @@ -141,7 +141,7 @@ extension APIService { .map { response -> AnyPublisher, Error> in let log = OSLog.api - return APIService.Persist.persistTimeline( + return APIService.Persist.persistStatus( managedObjectContext: self.backgroundManagedObjectContext, domain: mastodonAuthenticationBox.domain, query: query, diff --git a/Mastodon/Service/APIService/APIService+HomeTimeline.swift b/Mastodon/Service/APIService/APIService+HomeTimeline.swift index 0112a9da7..2fbb45e0b 100644 --- a/Mastodon/Service/APIService/APIService+HomeTimeline.swift +++ b/Mastodon/Service/APIService/APIService+HomeTimeline.swift @@ -40,7 +40,7 @@ extension APIService { authorization: authorization ) .flatMap { response -> AnyPublisher, Error> in - return APIService.Persist.persistTimeline( + return APIService.Persist.persistStatus( managedObjectContext: self.backgroundManagedObjectContext, domain: domain, query: query, diff --git a/Mastodon/Service/APIService/APIService+PublicTimeline.swift b/Mastodon/Service/APIService/APIService+PublicTimeline.swift index bfa3bb26e..cd02526d6 100644 --- a/Mastodon/Service/APIService/APIService+PublicTimeline.swift +++ b/Mastodon/Service/APIService/APIService+PublicTimeline.swift @@ -39,7 +39,7 @@ extension APIService { query: query ) .flatMap { response -> AnyPublisher, Error> in - return APIService.Persist.persistTimeline( + return APIService.Persist.persistStatus( managedObjectContext: self.backgroundManagedObjectContext, domain: domain, query: query, diff --git a/Mastodon/Service/APIService/APIService+Status.swift b/Mastodon/Service/APIService/APIService+Status.swift new file mode 100644 index 000000000..d02b04796 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Status.swift @@ -0,0 +1,54 @@ +// +// APIService+Status.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import DateToolsSwift +import MastodonSDK + +extension APIService { + + func status( + domain: String, + statusID: Mastodon.Entity.Status.ID, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = authorizationBox.userAuthorization + return Mastodon.API.Statuses.status( + session: session, + domain: domain, + statusID: statusID, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + return APIService.Persist.persistStatus( + managedObjectContext: self.backgroundManagedObjectContext, + domain: domain, + query: nil, + response: response.map { [$0] }, + persistType: .lookUp, + requestMastodonUserID: nil, + log: OSLog.api + ) + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift index 4f35a54c1..512e224d2 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift @@ -18,6 +18,7 @@ extension APIService.CoreData { for requestMastodonUser: MastodonUser?, in domain: String, entity: Mastodon.Entity.Account, + userCache: APIService.Persist.PersistCache?, networkDate: Date, log: OSLog ) -> (user: MastodonUser, isCreated: Bool) { @@ -29,15 +30,19 @@ extension APIService.CoreData { // fetch old mastodon user let oldMastodonUser: MastodonUser? = { - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: domain, id: entity.id) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil + if let userCache = userCache { + return userCache.dictionary[entity.id] + } else { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: domain, id: entity.id) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } } }() @@ -57,7 +62,7 @@ extension APIService.CoreData { into: managedObjectContext, property: mastodonUserProperty ) - + userCache?.dictionary[entity.id] = mastodonUser os_signpost(.event, log: log, name: "update database - process entity: createOrMergeMastodonUser", signpostID: processEntityTaskSignpostID, "did insert new mastodon user %{public}s: name %s", mastodonUser.identifier, mastodonUser.username) return (mastodonUser, true) } diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift similarity index 78% rename from Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift rename to Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift index 79fad947e..28bfd9727 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift @@ -1,5 +1,5 @@ // -// APIService+CoreData+Toot.swift +// APIService+CoreData+Status.swift // Mastodon // // Created by sxiaojian on 2021/2/3. @@ -13,32 +13,52 @@ import MastodonSDK extension APIService.CoreData { - static func createOrMergeToot( + static func createOrMergeStatus( into managedObjectContext: NSManagedObjectContext, for requestMastodonUser: MastodonUser?, - entity: Mastodon.Entity.Status, domain: String, + entity: Mastodon.Entity.Status, + tootCache: APIService.Persist.PersistCache?, + userCache: APIService.Persist.PersistCache?, networkDate: Date, log: OSLog ) -> (Toot: Toot, isTootCreated: Bool, isMastodonUserCreated: Bool) { - + 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, "process toot %{public}s", entity.id) + } + // build tree let reblog = entity.reblog.flatMap { entity -> Toot in - let (toot, _, _) = createOrMergeToot(into: managedObjectContext, for: requestMastodonUser, entity: entity, domain: domain, networkDate: networkDate, log: log) + let (toot, _, _) = createOrMergeStatus( + into: managedObjectContext, + for: requestMastodonUser, + domain: domain, + entity: entity, + tootCache: tootCache, + userCache: userCache, + networkDate: networkDate, + log: log + ) return toot } // fetch old Toot let oldToot: Toot? = { - let request = Toot.sortedFetchRequest - request.predicate = Toot.predicate(domain: domain, id: entity.id) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil + if let tootCache = tootCache { + return tootCache.dictionary[entity.id] + } else { + let request = Toot.sortedFetchRequest + request.predicate = Toot.predicate(domain: domain, id: entity.id) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } } }() @@ -47,10 +67,16 @@ extension APIService.CoreData { APIService.CoreData.merge(toot: oldToot, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, 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 (mastodonUser, isMastodonUserCreated) = createOrMergeMastodonUser(into: managedObjectContext, for: requestMastodonUser,in: domain, entity: entity.account, userCache: userCache, 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 replyTo: Toot? = { + // could be nil if target replyTo toot's persist task in the queue + guard let inReplyToID = entity.inReplyToID, + let replyTo = tootCache?.dictionary[inReplyToID] else { return nil } + return replyTo + }() let poll = entity.poll.flatMap { poll -> Poll in let options = poll.options.enumerated().map { i, option -> PollOption in let votedBy: MastodonUser? = (poll.ownVotes ?? []).contains(i) ? requestMastodonUser : nil @@ -92,6 +118,7 @@ extension APIService.CoreData { author: mastodonUser, reblog: reblog, application: application, + replyTo: replyTo, poll: poll, mentions: metions, emojis: emojis, @@ -103,6 +130,8 @@ extension APIService.CoreData { bookmarkedBy: (entity.bookmarked ?? false) ? requestMastodonUser : nil, pinnedBy: (entity.pinned ?? false) ? requestMastodonUser : nil ) + tootCache?.dictionary[entity.id] = toot + os_signpost(.event, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "did insert new tweet %{public}s: %s", mastodonUser.identifier, entity.id) return (toot, true, isMastodonUserCreated) } } diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+PersistCache.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistCache.swift new file mode 100644 index 000000000..16461494a --- /dev/null +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistCache.swift @@ -0,0 +1,66 @@ +// +// APIService+Persist+PersistCache.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +extension APIService.Persist { + + class PersistCache { + var dictionary: [String : T] = [:] + } + +} + +extension APIService.Persist.PersistCache where T == Toot { + + static func ids(for toots: [Mastodon.Entity.Status]) -> Set { + var value = Set() + for toot in toots { + value = value.union(ids(for: toot)) + } + return value + } + + static func ids(for toot: Mastodon.Entity.Status) -> Set { + var value = Set() + value.insert(toot.id) + if let inReplyToID = toot.inReplyToID { + value.insert(inReplyToID) + } + if let reblog = toot.reblog { + value = value.union(ids(for: reblog)) + } + return value + } + +} + +extension APIService.Persist.PersistCache where T == MastodonUser { + + static func ids(for toots: [Mastodon.Entity.Status]) -> Set { + var value = Set() + for toot in toots { + value = value.union(ids(for: toot)) + } + return value + } + + static func ids(for toot: Mastodon.Entity.Status) -> Set { + var value = Set() + value.insert(toot.account.id) + if let inReplyToAccountID = toot.inReplyToAccountID { + value.insert(inReplyToAccountID) + } + if let reblog = toot.reblog { + value = value.union(ids(for: reblog)) + } + return value + } + +} diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift new file mode 100644 index 000000000..b74bb7771 --- /dev/null +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift @@ -0,0 +1,226 @@ +// +// APIService+Persist+PersistMemo.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import os.log +import Foundation +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService.Persist { + + class PersistMemo { + + let status: T + let children: [PersistMemo] + let memoType: MemoType + let statusProcessType: ProcessType + let authorProcessType: ProcessType + + enum MemoType { + case homeTimeline + case mentionTimeline + case userTimeline + case publicTimeline + case likeList + case searchList + case lookUp + + case reblog + + var flag: String { + switch self { + case .homeTimeline: return "H" + case .mentionTimeline: return "M" + case .userTimeline: return "U" + case .publicTimeline: return "P" + case .likeList: return "L" + case .searchList: return "S" + case .lookUp: return "LU" + case .reblog: return "R" + } + } + } + + enum ProcessType { + case create + case merge + + var flag: String { + switch self { + case .create: return "+" + case .merge: return "~" + } + } + } + + init( + status: T, + children: [PersistMemo], + memoType: MemoType, + statusProcessType: ProcessType, + authorProcessType: ProcessType + ) { + self.status = status + self.children = children + self.memoType = memoType + self.statusProcessType = statusProcessType + self.authorProcessType = authorProcessType + } + + } + +} + +extension APIService.Persist.PersistMemo { + + 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 authorProcessType { + 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 + } + +} + +extension APIService.Persist.PersistMemo where T == Toot, U == MastodonUser { + + static func createOrMergeToot( + into managedObjectContext: NSManagedObjectContext, + for requestMastodonUser: MastodonUser?, + requestMastodonUserID: MastodonUser.ID?, + domain: String, + entity: Mastodon.Entity.Status, + memoType: MemoType, + tootCache: APIService.Persist.PersistCache?, + userCache: APIService.Persist.PersistCache?, + networkDate: Date, + log: OSLog + ) -> APIService.Persist.PersistMemo { + 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 reblogMemo = entity.reblog.flatMap { entity -> APIService.Persist.PersistMemo in + createOrMergeToot( + into: managedObjectContext, + for: requestMastodonUser, + requestMastodonUserID: requestMastodonUserID, + domain: domain, + entity: entity, + memoType: .reblog, + tootCache: tootCache, + userCache: userCache, + networkDate: networkDate, + log: log + ) + } + let children = [reblogMemo].compactMap { $0 } + + + let (toot, isTootCreated, isMastodonUserCreated) = APIService.CoreData.createOrMergeStatus( + into: managedObjectContext, + for: requestMastodonUser, + domain: domain, + entity: entity, + tootCache: tootCache, + userCache: userCache, + networkDate: networkDate, + log: log + ) + let memo = APIService.Persist.PersistMemo( + status: toot, + children: children, + memoType: memoType, + statusProcessType: isTootCreated ? .create : .merge, + authorProcessType: isMastodonUserCreated ? .create : .merge + ) + + switch (memo.statusProcessType, memoType) { + case (.create, .homeTimeline), (.merge, .homeTimeline): + let timelineIndex = toot.homeTimelineIndexes? + .first { $0.userID == requestMastodonUserID } + guard let requestMastodonUserID = requestMastodonUserID else { + assertionFailure() + break + } + if timelineIndex == nil { + // make it indexed + let timelineIndexProperty = HomeTimelineIndex.Property(domain: domain, userID: requestMastodonUserID) + let _ = HomeTimelineIndex.insert(into: managedObjectContext, property: timelineIndexProperty, toot: toot) + } else { + // enity already in home timeline + } + case (.create, .mentionTimeline), (.merge, .mentionTimeline): + break + // TODO: + default: + break + } + + return memo + } + + func log(indentLevel: Int = 0) -> String { + let indent = Array(repeating: " ", count: indentLevel).joined() + let preview = status.content.prefix(32).replacingOccurrences(of: "\n", with: " ") + let message = "\(indent)[\(statusProcessType.flag)\(memoType.flag)](\(status.id)) [\(authorProcessType.flag)](\(status.author.id))@\(status.author.username) ~> \(preview)" + + 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 + } + +} + diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift new file mode 100644 index 000000000..2944e66a5 --- /dev/null +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift @@ -0,0 +1,254 @@ +// +// APIService+Persist+Status.swift +// Mastodon +// +// Created by sxiaojian on 2021/1/27. +// + +import os.log +import func QuartzCore.CACurrentMediaTime +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService.Persist { + + enum PersistTimelineType { + case `public` + case home + case likeList + case lookUp + } + + static func persistStatus( + managedObjectContext: NSManagedObjectContext, + domain: String, + query: Mastodon.API.Timeline.TimelineQuery?, + response: Mastodon.Response.Content<[Mastodon.Entity.Status]>, + persistType: PersistTimelineType, + requestMastodonUserID: MastodonUser.ID?, // could be nil when response from public endpoint + log: OSLog + ) -> AnyPublisher, Never> { + return managedObjectContext.performChanges { + 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) + + 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 & users into cache", signpostID: cacheTaskSignpostID) + + // contains reblog + let tootCache: PersistCache = { + let cache = PersistCache() + let cacheIDs = PersistCache.ids(for: toots) + let cachedToots: [Toot] = { + let request = Toot.sortedFetchRequest + let ids = Array(cacheIDs) + 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 [] + } + }() + for toot in cachedToots { + cache.dictionary[toot.id] = toot + } + os_signpost(.event, log: log, name: "load toot into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld toots", cachedToots.count) + return cache + }() + + let userCache: PersistCache = { + let cache = PersistCache() + let cacheIDs = PersistCache.ids(for: toots) + let cachedMastodonUsers: [MastodonUser] = { + let request = MastodonUser.sortedFetchRequest + let ids = Array(cacheIDs) + request.predicate = MastodonUser.predicate(domain: domain, ids: ids) + //request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return [] + } + }() + for mastodonuser in cachedMastodonUsers { + cache.dictionary[mastodonuser.id] = mastodonuser + } + os_signpost(.event, log: log, name: "load user into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld users", cachedMastodonUsers.count) + return cache + }() + + os_signpost(.end, log: log, name: "load toots & users into cache", signpostID: cacheTaskSignpostID) + + // remote timeline merge local timeline record set + // declare it before persist + let mergedOldTootsInTimeline = tootCache.dictionary.values.filter { + return $0.homeTimelineIndexes?.contains(where: { $0.userID == requestMastodonUserID }) ?? false + } + + let updateDatabaseTaskSignpostID = OSSignpostID(log: log) + let memoType: PersistMemo.MemoType = { + switch persistType { + case .home: return .homeTimeline + case .public: return .publicTimeline + case .likeList: return .likeList + case .lookUp: return .lookUp + } + }() + + var persistMemos: [PersistMemo] = [] + 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 memo = PersistMemo.createOrMergeToot( + into: managedObjectContext, + for: requestMastodonUser, + requestMastodonUserID: requestMastodonUserID, + domain: domain, + entity: entity, + memoType: memoType, + tootCache: tootCache, + userCache: userCache, + networkDate: response.networkDate, + log: log + ) + persistMemos.append(memo) + } // end for… + os_signpost(.end, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID) + + // home timeline tasks + switch persistType { + case .home: + guard let query = query, + let requestMastodonUserID = requestMastodonUserID else { + assertionFailure() + return + } + // Task 1: update anchor hasMore + // update maxID anchor hasMore attribute when fetching on home 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 _oldestMemo = persistMemos + .sorted(by: { $0.status.createdAt < $1.status.createdAt }) + .first + if let oldestMemo = _oldestMemo { + 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 = oldestMemo.status.id == anchorToot.id + if (isNoOverlap || isOnlyOverlapItself) && !isAnchorEqualOldestRecord { + if persistType == .home { + let timelineIndex = oldestMemo.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 = oldestMemo.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 = persistMemos + .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 = persistMemos + .map { record in record.count() } + .reduce(into: PersistMemo.Counting(), { result, next in result = result + next }) + let newTweetsInTimeLineCount = persistMemos.reduce(0, { result, next in + return next.statusProcessType == .create ? result + 1 : result + }) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: tweet: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTweetsInTimeLineCount, counting.status.create, mergedOldTootsInTimeline.count, counting.status.merge) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: twitter 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: + break + case .failure(let error): + #if DEBUG + debugPrint(error) + #endif + assertionFailure(error.localizedDescription) + } + }) + .eraseToAnyPublisher() + } +} diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift deleted file mode 100644 index 460cab023..000000000 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift +++ /dev/null @@ -1,446 +0,0 @@ -// -// APIService+Persist+Timeline.swift -// Mastodon -// -// Created by sxiaojian on 2021/1/27. -// - -import os.log -import func QuartzCore.CACurrentMediaTime -import Foundation -import Combine -import CoreData -import CoreDataStack -import MastodonSDK - -extension APIService.Persist { - - enum PersistTimelineType { - case `public` - case home - case likeList - } - - static func persistTimeline( - managedObjectContext: NSManagedObjectContext, - domain: String, - query: Mastodon.API.Timeline.TimelineQuery, - response: Mastodon.Response.Content<[Mastodon.Entity.Status]>, - 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 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 - case .likeList: return .favoriteTimeline - } - }() - - 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: - break - case .failure(let error): - #if DEBUG - debugPrint(error) - #endif - assertionFailure(error.localizedDescription) - } - }) - .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, userID: requestMastodonUserID) - - let _ = HomeTimelineIndex.insert( - into: managedObjectContext, - property: timelineIndexProperty, - toot: status - ) - } else { - // enity already in home timeline - } - default: - break - } - - return result - } - - } - -} - diff --git a/Mastodon/Service/StatusPrefetchingService.swift b/Mastodon/Service/StatusPrefetchingService.swift new file mode 100644 index 000000000..d4332fe16 --- /dev/null +++ b/Mastodon/Service/StatusPrefetchingService.swift @@ -0,0 +1,84 @@ +// +// StatusPrefetchingService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import os.log +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class StatusPrefetchingService { + + typealias TaskID = String + + let workingQueue = DispatchQueue(label: "status-prefetching-service-working-queue") + + var disposeBag = Set() + private(set) var statusPrefetchingDisposeBagDict: [TaskID: AnyCancellable] = [:] + + weak var apiService: APIService? + + init(apiService: APIService) { + self.apiService = apiService + } + +} + +extension StatusPrefetchingService { + + func prefetchReplyTo( + domain: String, + statusObjectID: NSManagedObjectID, + statusID: Mastodon.Entity.Status.ID, + replyToStatusID: Mastodon.Entity.Status.ID, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) { + workingQueue.async { [weak self] in + guard let self = self, let apiService = self.apiService else { return } + let taskID = domain + "@" + statusID + "->" + replyToStatusID + guard self.statusPrefetchingDisposeBagDict[taskID] == nil else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: prefetching replyTo: %s", ((#file as NSString).lastPathComponent), #line, #function, taskID) + + self.statusPrefetchingDisposeBagDict[taskID] = apiService.status( + domain: domain, + statusID: replyToStatusID, + authorizationBox: authorizationBox + ) + .sink(receiveCompletion: { [weak self] completion in + // remove task when completed + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: prefeched replyTo: %s", ((#file as NSString).lastPathComponent), #line, #function, taskID) + self.statusPrefetchingDisposeBagDict[taskID] = nil + }, receiveValue: { [weak self] _ in + guard let self = self else { return } + let backgroundManagedObjectContext = apiService.backgroundManagedObjectContext + backgroundManagedObjectContext.performChanges { + guard let status = backgroundManagedObjectContext.object(with: statusObjectID) as? Toot else { return } + do { + let predicate = Toot.predicate(domain: domain, id: replyToStatusID) + let request = Toot.sortedFetchRequest + request.predicate = predicate + request.returnsObjectsAsFaults = false + request.fetchLimit = 1 + guard let replyTo = try backgroundManagedObjectContext.fetch(request).first else { return } + status.update(replyTo: replyTo) + } catch { + assertionFailure(error.localizedDescription) + } + } + .sink { _ in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update status replyTo: %s", ((#file as NSString).lastPathComponent), #line, #function, taskID) + } receiveValue: { _ in + // do nothing + } + .store(in: &self.disposeBag) + }) + } + } + +} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index fe8cd5831..28325f94e 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -26,6 +26,7 @@ class AppContext: ObservableObject { let emojiService: EmojiService let audioPlaybackService = AudioPlaybackService() let videoPlaybackService = VideoPlaybackService() + let statusPrefetchingService: StatusPrefetchingService let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! @@ -52,6 +53,9 @@ class AppContext: ObservableObject { emojiService = EmojiService( apiService: apiService ) + statusPrefetchingService = StatusPrefetchingService( + apiService: _apiService + ) documentStore = DocumentStore() documentStoreSubscription = documentStore.objectWillChange diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift index f01e6cb47..cb33bc09b 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift @@ -2,7 +2,52 @@ // Mastodon+API+Statuses.swift // // -// Created by MainasuK Cirno on 2021-3-12. +// Created by MainasuK Cirno on 2021-3-10. // import Foundation +import Combine + +extension Mastodon.API.Statuses { + + static func viewStatusEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + let pathComponent = "statuses/" + statusID + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// View specific status + /// + /// View information about a status + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/10 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: id for status + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func status( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Poll.ID, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: viewStatusEndpointURL(domain: domain, statusID: statusID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index dfba19bf8..b8efcdee2 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -97,8 +97,8 @@ extension Mastodon.API { public enum OAuth { } public enum Onboarding { } public enum Polls { } - public enum Statuses { } public enum Reblog { } + public enum Statuses { } public enum Timeline { } } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift index 31a8806a7..490429fce 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift @@ -1,5 +1,5 @@ // -// Mastodon+Entity+Toot.swift +// Mastodon+Entity+Status.swift // // // Created by MainasuK Cirno on 2021/1/27.