From 5f1800b3533e1e277b945f36238ca0eb12866361 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Sun, 7 Feb 2021 14:42:50 +0800 Subject: [PATCH] feat: HomeTimeline --- .../CoreData.xcdatamodel/contents | 12 +- CoreDataStack/Entity/HomeTimelineIndex.swift | 21 +- CoreDataStack/Entity/Toot.swift | 13 +- Mastodon.xcodeproj/project.pbxproj | 88 +++++- Mastodon/Diffiable/Item/Item.swift | 40 ++- .../Diffiable/Section/TimelineSection.swift | 19 +- Mastodon/Protocol/DisposeBagCollectable.swift | 13 + Mastodon/Protocol/ScrollViewContainer.swift | 19 ++ .../StatusProvider/StatusProvider.swift | 16 + ...eTimelineViewController+DebugAction.swift} | 6 +- ...imelineViewController+StatusProvider.swift | 50 +++ .../HomeTimelineViewController.swift | 299 ++++++++++++++++++ .../HomeTimelineViewModel+Diffable.swift | 168 ++++++++++ ...omeTimelineViewModel+LoadLatestState.swift | 122 +++++++ ...omeTimelineViewModel+LoadMiddleState.swift | 107 +++++++ ...omeTimelineViewModel+LoadOldestState.swift | 110 +++++++ .../HomeTimeline/HomeTimelineViewModel.swift | 129 ++++++++ .../HomeTimeline/HomeViewController.swift | 71 ----- .../Scene/HomeTimeline/HomeViewModel.swift | 24 -- .../Scene/MainTab/MainTabBarController.swift | 3 +- .../PublicTimelineViewController.swift | 6 +- .../PublicTimelineViewModel+Diffable.swift | 2 +- ...licTimelineViewModel+LoadMiddleState.swift | 4 +- .../TimelineMiddleLoaderTableViewCell.swift | 2 +- .../Persist/APIService+Persist+Timeline.swift | 3 +- .../ControlContainableScrollViews.swift | 63 ++++ 26 files changed, 1267 insertions(+), 143 deletions(-) create mode 100644 Mastodon/Protocol/DisposeBagCollectable.swift create mode 100644 Mastodon/Protocol/ScrollViewContainer.swift create mode 100644 Mastodon/Protocol/StatusProvider/StatusProvider.swift rename Mastodon/Scene/HomeTimeline/{HomeViewController+DebugAction.swift => HomeTimelineViewController+DebugAction.swift} (94%) create mode 100644 Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift create mode 100644 Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift create mode 100644 Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift create mode 100644 Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift create mode 100644 Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift create mode 100644 Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift create mode 100644 Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift delete mode 100644 Mastodon/Scene/HomeTimeline/HomeViewController.swift delete mode 100644 Mastodon/Scene/HomeTimeline/HomeViewModel.swift create mode 100644 Mastodon/Vender/ControlContainableScrollViews.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index c0e864dce..0c42dfc6b 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -32,7 +32,7 @@ - + @@ -59,7 +59,7 @@ - + @@ -106,10 +106,10 @@ - + - + @@ -127,6 +127,6 @@ - + - + \ No newline at end of file diff --git a/CoreDataStack/Entity/HomeTimelineIndex.swift b/CoreDataStack/Entity/HomeTimelineIndex.swift index 93fa0fb59..192e06e52 100644 --- a/CoreDataStack/Entity/HomeTimelineIndex.swift +++ b/CoreDataStack/Entity/HomeTimelineIndex.swift @@ -38,7 +38,7 @@ extension HomeTimelineIndex { index.identifier = property.identifier index.domain = property.domain - index.userID = toot.author.id + index.userID = property.userID index.createdAt = toot.createdAt index.toot = toot @@ -63,10 +63,12 @@ extension HomeTimelineIndex { public struct Property { public let identifier: String public let domain: String - - public init(domain: String) { + public let userID: String + + public init(domain: String,userID: String) { self.identifier = UUID().uuidString + "@" + domain self.domain = domain + self.userID = userID } } } @@ -76,4 +78,15 @@ extension HomeTimelineIndex: Managed { return [NSSortDescriptor(keyPath: \HomeTimelineIndex.createdAt, ascending: false)] } } - +extension HomeTimelineIndex { + + public static func predicate(userID: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(HomeTimelineIndex.userID), userID) + } + + + public static func notDeleted() -> NSPredicate { + return NSPredicate(format: "%K == nil", #keyPath(HomeTimelineIndex.deletedAt)) + } + +} diff --git a/CoreDataStack/Entity/Toot.swift b/CoreDataStack/Entity/Toot.swift index 6dab70104..7719d6c03 100644 --- a/CoreDataStack/Entity/Toot.swift +++ b/CoreDataStack/Entity/Toot.swift @@ -39,10 +39,10 @@ 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 favouritedBy: MastodonUser? - @NSManaged public private(set) var rebloggedBy: MastodonUser? - @NSManaged public private(set) var mutedBy: MastodonUser? - @NSManaged public private(set) var bookmarkedBy: MastodonUser? + @NSManaged public private(set) var favouritedBy: Set? + @NSManaged public private(set) var rebloggedBy: Set? + @NSManaged public private(set) var mutedBy: Set? + @NSManaged public private(set) var bookmarkedBy: Set? // one-to-one relastionship @NSManaged public private(set) var pinnedBy: MastodonUser? @@ -104,6 +104,8 @@ public extension Toot { toot.author = author toot.reblog = reblog + toot.pinnedBy = pinnedBy + if let mentions = mentions { toot.mutableSetValue(forKey: #keyPath(Toot.mentions)).addObjects(from: mentions) } @@ -125,9 +127,6 @@ public extension Toot { if let bookmarkedBy = bookmarkedBy { toot.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).add(bookmarkedBy) } - if let pinnedBy = pinnedBy { - toot.mutableSetValue(forKey: #keyPath(Toot.pinnedBy)).add(pinnedBy) - } toot.updatedAt = property.networkDate diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 70b41720b..45018ebda 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -15,6 +15,14 @@ 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAB925CB9B0500C9ED86 /* UIView.swift */; }; 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */; }; 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */; }; + 2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1D425CD465300561493 /* HomeTimelineViewController.swift */; }; + 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1DE25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift */; }; + 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1E425CD46C100561493 /* HomeTimelineViewModel.swift */; }; + 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1EA25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift */; }; + 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1F025CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift */; }; + 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1F625CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift */; }; + 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1FD25CD481700561493 /* StatusProvider.swift */; }; + 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */; }; 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 2D42FF6025C8177C004A627A /* ActiveLabel */; }; 2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF6A25C817D2004A627A /* MastodonContent.swift */; }; 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */; }; @@ -23,6 +31,11 @@ 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */; }; 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */; }; 2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46976325C2A71500CF4AA9 /* UIIamge.swift */; }; + 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */; }; + 2D5A3D1125CF87AA002347D6 /* AvatarBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D1025CF87AA002347D6 /* AvatarBarButtonItem.swift */; }; + 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 */; }; 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; }; 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; }; @@ -55,7 +68,6 @@ DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; }; DB084B5725CBC56C00F898ED /* Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Toot.swift */; }; DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; }; - DB0AC70A25CD2E0300D75117 /* HomeViewController+DebugAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC70925CD2E0300D75117 /* HomeViewController+DebugAction.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 */; }; @@ -97,7 +109,6 @@ DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF54225C13647002E6C99 /* SceneCoordinator.swift */; }; DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF54325C13647002E6C99 /* NeedsDependency.swift */; }; DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */; }; - DB8AF55725C137A8002E6C99 /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF55625C137A8002E6C99 /* HomeViewController.swift */; }; DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF55C25C138B7002E6C99 /* UIViewController.swift */; }; DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */; }; DB98334725C8056600AD9700 /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98334625C8056600AD9700 /* AuthenticationViewModel.swift */; }; @@ -107,7 +118,6 @@ DB98338725C945ED00AD9700 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98338525C945ED00AD9700 /* Strings.swift */; }; DB98338825C945ED00AD9700 /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98338625C945ED00AD9700 /* Assets.swift */; }; DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98339B25C96DE600AD9700 /* APIService+Account.swift */; }; - DBD4ED1125CC0FEB0041B741 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD4ED1025CC0FEB0041B741 /* HomeViewModel.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; }; /* End PBXBuildFile section */ @@ -172,6 +182,14 @@ 2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentOffsetAdjustableTimelineViewControllerDelegate.swift; sourceTree = ""; }; + 2D38F1D425CD465300561493 /* HomeTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineViewController.swift; sourceTree = ""; }; + 2D38F1DE25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+StatusProvider.swift"; sourceTree = ""; }; + 2D38F1E425CD46C100561493 /* HomeTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineViewModel.swift; sourceTree = ""; }; + 2D38F1EA25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+LoadLatestState.swift"; sourceTree = ""; }; + 2D38F1F025CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; + 2D38F1F625CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+LoadOldestState.swift"; sourceTree = ""; }; + 2D38F1FD25CD481700561493 /* StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusProvider.swift; sourceTree = ""; }; + 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisposeBagCollectable.swift; sourceTree = ""; }; 2D42FF6A25C817D2004A627A /* MastodonContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonContent.swift; sourceTree = ""; }; 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionToolBarContainer.swift; sourceTree = ""; }; 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HitTestExpandedButton.swift; sourceTree = ""; }; @@ -179,6 +197,11 @@ 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = ""; }; 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = ""; }; 2D46976325C2A71500CF4AA9 /* UIIamge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIIamge.swift; sourceTree = ""; }; + 2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlContainableScrollViews.swift; sourceTree = ""; }; + 2D5A3D1025CF87AA002347D6 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; + 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 = ""; }; 2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; @@ -215,7 +238,6 @@ 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 = ""; }; DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = ""; }; - DB0AC70925CD2E0300D75117 /* HomeViewController+DebugAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeViewController+DebugAction.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; }; @@ -264,7 +286,6 @@ DB8AF54225C13647002E6C99 /* SceneCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneCoordinator.swift; sourceTree = ""; }; DB8AF54325C13647002E6C99 /* NeedsDependency.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NeedsDependency.swift; sourceTree = ""; }; DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainTabBarController.swift; sourceTree = ""; }; - DB8AF55625C137A8002E6C99 /* HomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewController.swift; sourceTree = ""; }; DB8AF55C25C138B7002E6C99 /* UIViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineIndex.swift; sourceTree = ""; }; DB98334625C8056600AD9700 /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; @@ -274,7 +295,6 @@ DB98338525C945ED00AD9700 /* Strings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; DB98338625C945ED00AD9700 /* Assets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = ""; }; DB98339B25C96DE600AD9700 /* APIService+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Account.swift"; sourceTree = ""; }; - DBD4ED1025CC0FEB0041B741 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = ""; }; DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = ""; }; @@ -354,6 +374,29 @@ path = Content; sourceTree = ""; }; + 2D38F1D325CD463600561493 /* HomeTimeline */ = { + isa = PBXGroup; + children = ( + 2D38F1D425CD465300561493 /* HomeTimelineViewController.swift */, + 2D38F1DE25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift */, + 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */, + 2D38F1E425CD46C100561493 /* HomeTimelineViewModel.swift */, + 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */, + 2D38F1EA25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift */, + 2D38F1F025CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift */, + 2D38F1F625CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift */, + ); + path = HomeTimeline; + sourceTree = ""; + }; + 2D38F1FC25CD47D900561493 /* StatusProvider */ = { + isa = PBXGroup; + children = ( + 2D38F1FD25CD481700561493 /* StatusProvider.swift */, + ); + path = StatusProvider; + sourceTree = ""; + }; 2D42FF7C25C82207004A627A /* ToolBar */ = { isa = PBXGroup; children = ( @@ -367,10 +410,19 @@ children = ( DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */, 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */, + 2D5A3D1025CF87AA002347D6 /* AvatarBarButtonItem.swift */, ); path = Button; sourceTree = ""; }; + 2D5A3D0125CF8640002347D6 /* Vender */ = { + isa = PBXGroup; + children = ( + 2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */, + ); + path = Vender; + sourceTree = ""; + }; 2D61335525C1886800CAE157 /* Service */ = { isa = PBXGroup; children = ( @@ -392,8 +444,11 @@ isa = PBXGroup; children = ( DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */, + 2D38F1FC25CD47D900561493 /* StatusProvider */, 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */, 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */, + 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */, + 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */, ); path = Protocol; sourceTree = ""; @@ -561,6 +616,7 @@ children = ( DB427DE325BAA00100D1B89D /* Info.plist */, DB89BA1025C10FF5008580ED /* Mastodon.entitlements */, + 2D5A3D0125CF8640002347D6 /* Vender */, 2D76319C25C151DE00929FB9 /* Diffiable */, DB8AF52A25C13561002E6C99 /* State */, 2D61335525C1886800CAE157 /* Service */, @@ -718,6 +774,7 @@ DB8AF55525C1379F002E6C99 /* Scene */ = { isa = PBXGroup; children = ( + 2D38F1D325CD463600561493 /* HomeTimeline */, 2D7631A425C1532200929FB9 /* Share */, DB8AF54E25C13703002E6C99 /* MainTab */, DB01409B25C40BB600F9F3CF /* Authentication */, @@ -766,9 +823,6 @@ DBD4ED0B25CC0FD40041B741 /* HomeTimeline */ = { isa = PBXGroup; children = ( - DB8AF55625C137A8002E6C99 /* HomeViewController.swift */, - DB0AC70925CD2E0300D75117 /* HomeViewController+DebugAction.swift */, - DBD4ED1025CC0FEB0041B741 /* HomeViewModel.swift */, ); path = HomeTimeline; sourceTree = ""; @@ -1133,7 +1187,10 @@ DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */, 2D7631B325C159F700929FB9 /* Item.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, + 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, + 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, + 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, 2D152A8C25C295CC009AA50C /* TimelinePostView.swift in Sources */, 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, @@ -1142,15 +1199,18 @@ DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */, + 2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */, DB98338825C945ED00AD9700 /* Assets.swift in Sources */, 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, - DB0AC70A25CD2E0300D75117 /* HomeViewController+DebugAction.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, + 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */, + 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, + 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */, 2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */, @@ -1164,26 +1224,27 @@ DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, DB98334725C8056600AD9700 /* AuthenticationViewModel.swift in Sources */, + 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, 2D76319F25C1521200929FB9 /* TimelineSection.swift in Sources */, DB084B5725CBC56C00F898ED /* Toot.swift in Sources */, - DBD4ED1125CC0FEB0041B741 /* HomeViewModel.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */, + 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */, DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */, 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */, 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */, + 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, 2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */, DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */, - DB8AF55725C137A8002E6C99 /* HomeViewController.swift in Sources */, 2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */, 2D7631A825C1535600929FB9 /* TimelinePostTableViewCell.swift in Sources */, 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */, @@ -1193,6 +1254,9 @@ DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, + 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, + 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, + 2D5A3D1125CF87AA002347D6 /* AvatarBarButtonItem.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index 31db137b7..8ee2f9a6c 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -12,22 +12,49 @@ import MastodonSDK /// Note: update Equatable when change case enum Item { + case homeTimelineIndex(objectID: NSManagedObjectID, attribute: Attribute) + // normal list case toot(objectID: NSManagedObjectID) // loader - case middleLoader(tootID: String) + case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID) + case publicMiddleLoader(tootID: String) case bottomLoader } +extension Item { + class Attribute: Hashable { + var separatorLineStyle: SeparatorLineStyle = .indent + + static func == (lhs: Item.Attribute, rhs: Item.Attribute) -> Bool { + return lhs.separatorLineStyle == rhs.separatorLineStyle + } + + func hash(into hasher: inout Hasher) { + hasher.combine(separatorLineStyle) + } + + enum SeparatorLineStyle { + case indent // alignment to name label + case expand // alignment to table view two edges + case normal // alignment to readable guideline + } + } +} + extension Item: Equatable { static func == (lhs: Item, rhs: Item) -> Bool { switch (lhs, rhs) { + case (.homeTimelineIndex(let objectIDLeft, _), .homeTimelineIndex(let objectIDRight, _)): + return objectIDLeft == objectIDRight case (.toot(let objectIDLeft), .toot(let objectIDRight)): return objectIDLeft == objectIDRight case (.bottomLoader, .bottomLoader): return true - case (.middleLoader(let upperLeft), .middleLoader(let upperRight)): + case (.publicMiddleLoader(let upperLeft), .publicMiddleLoader(let upperRight)): + return upperLeft == upperRight + case (.homeMiddleLoader(let upperLeft), .homeMiddleLoader(let upperRight)): return upperLeft == upperRight default: return false @@ -38,10 +65,15 @@ extension Item: Equatable { extension Item: Hashable { func hash(into hasher: inout Hasher) { switch self { + case .homeTimelineIndex(let objectID, _): + hasher.combine(objectID) case .toot(let objectID): hasher.combine(objectID) - case .middleLoader(let upper): - hasher.combine(String(describing: Item.middleLoader.self)) + case .publicMiddleLoader(let upper): + hasher.combine(String(describing: Item.publicMiddleLoader.self)) + hasher.combine(upper) + case .homeMiddleLoader(upperTimelineIndexAnchorObjectID: let upper): + hasher.combine(String(describing: Item.homeMiddleLoader.self)) hasher.combine(upper) case .bottomLoader: hasher.combine(String(describing: Item.bottomLoader.self)) diff --git a/Mastodon/Diffiable/Section/TimelineSection.swift b/Mastodon/Diffiable/Section/TimelineSection.swift index 1d8406c94..bc0aed87b 100644 --- a/Mastodon/Diffiable/Section/TimelineSection.swift +++ b/Mastodon/Diffiable/Section/TimelineSection.swift @@ -28,6 +28,16 @@ extension TimelineSection { guard let timelinePostTableViewCellDelegate = timelinePostTableViewCellDelegate else { return UITableViewCell() } switch item { + case .homeTimelineIndex(objectID: let objectID, attribute: _): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelinePostTableViewCell.self), for: indexPath) as! TimelinePostTableViewCell + + // configure cell + managedObjectContext.performAndWait { + let timelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex + TimelineSection.configure(cell: cell, timestampUpdatePublisher: timestampUpdatePublisher, toot: timelineIndex.toot) + } + cell.delegate = timelinePostTableViewCellDelegate + return cell case .toot(let objectID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelinePostTableViewCell.self), for: indexPath) as! TimelinePostTableViewCell @@ -38,10 +48,15 @@ extension TimelineSection { } cell.delegate = timelinePostTableViewCellDelegate return cell - case .middleLoader(let upperTimelineTootID): + case .publicMiddleLoader(let upperTimelineTootID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell cell.delegate = timelineMiddleLoaderTableViewCellDelegate - timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineTootID: upperTimelineTootID) + timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineTootID: upperTimelineTootID,timelineIndexobjectID: nil) + return cell + case .homeMiddleLoader(let upperTimelineIndexObjectID): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell + cell.delegate = timelineMiddleLoaderTableViewCellDelegate + timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineTootID: nil,timelineIndexobjectID: upperTimelineIndexObjectID) return cell case .bottomLoader: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell diff --git a/Mastodon/Protocol/DisposeBagCollectable.swift b/Mastodon/Protocol/DisposeBagCollectable.swift new file mode 100644 index 000000000..a8afde9d4 --- /dev/null +++ b/Mastodon/Protocol/DisposeBagCollectable.swift @@ -0,0 +1,13 @@ +// +// DisposeBagCollectable.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/5. +// + +import Foundation +import Combine + +protocol DisposeBagCollectable: class { + var disposeBag: Set { get set } +} diff --git a/Mastodon/Protocol/ScrollViewContainer.swift b/Mastodon/Protocol/ScrollViewContainer.swift new file mode 100644 index 000000000..ae79d0e0f --- /dev/null +++ b/Mastodon/Protocol/ScrollViewContainer.swift @@ -0,0 +1,19 @@ +// +// ScrollViewContainer.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/7. +// + +import UIKit + +protocol ScrollViewContainer: UIViewController { + var scrollView: UIScrollView { get } + func scrollToTop(animated: Bool) +} + +extension ScrollViewContainer { + func scrollToTop(animated: Bool) { + scrollView.scrollRectToVisible(CGRect(origin: .zero, size: CGSize(width: 1, height: 1)), animated: animated) + } +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider.swift b/Mastodon/Protocol/StatusProvider/StatusProvider.swift new file mode 100644 index 000000000..667fc05ac --- /dev/null +++ b/Mastodon/Protocol/StatusProvider/StatusProvider.swift @@ -0,0 +1,16 @@ +// +// StatusProvider.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/5. +// + +import UIKit +import Combine +import CoreDataStack + +protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewController { + func toot() -> Future + func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future + func toot(for cell: UICollectionViewCell) -> Future +} diff --git a/Mastodon/Scene/HomeTimeline/HomeViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift similarity index 94% rename from Mastodon/Scene/HomeTimeline/HomeViewController+DebugAction.swift rename to Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 2839065dc..84c0885d8 100644 --- a/Mastodon/Scene/HomeTimeline/HomeViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -1,5 +1,5 @@ // -// HomeViewController+DebugAction.swift +// HomeTimelineViewController+DebugAction.swift // Mastodon // // Created by MainasuK Cirno on 2021-2-5. @@ -9,7 +9,7 @@ import os.log import UIKit #if DEBUG -extension HomeViewController { +extension HomeTimelineViewController { var debugMenu: UIMenu { let menu = UIMenu( title: "Debug Tools", @@ -27,7 +27,7 @@ extension HomeViewController { } } -extension HomeViewController { +extension HomeTimelineViewController { @objc private func signOutAction(_ sender: UIAction) { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift new file mode 100644 index 000000000..4c4bda1da --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift @@ -0,0 +1,50 @@ +// +// HomeTimelineViewController+StatusProvider.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/5. +// + +import os.log +import UIKit +import Combine +import CoreDataStack + +// MARK: - StatusProvider +extension HomeTimelineViewController: StatusProvider { + + func toot() -> Future { + return Future { promise in promise(.success(nil)) } + } + + func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + return Future { promise in + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + promise(.success(nil)) + return + } + guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + promise(.success(nil)) + return + } + + switch item { + case .homeTimelineIndex(let objectID, _): + let managedObjectContext = self.viewModel.fetchedResultsController.managedObjectContext + managedObjectContext.perform { + let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex + promise(.success(timelineIndex?.toot)) + } + default: + promise(.success(nil)) + } + } + } + + func toot(for cell: UICollectionViewCell) -> Future { + return Future { promise in promise(.success(nil)) } + } + +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift new file mode 100644 index 000000000..21e3d6fea --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -0,0 +1,299 @@ +// +// HomeTimelineViewController.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/5. +// + +import os.log +import UIKit +import AVKit +import Combine +import CoreData +import CoreDataStack +import GameplayKit +import MastodonSDK +import AlamofireImage + +final class HomeTimelineViewController: UIViewController, NeedsDependency,TimelinePostTableViewCellDelegate { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + private(set) lazy var viewModel = HomeTimelineViewModel(context: context) + + let avatarBarButtonItem = AvatarBarButtonItem() + + let tableView: UITableView = { + let tableView = ControlContainableTableView() + tableView.register(TimelinePostTableViewCell.self, forCellReuseIdentifier: String(describing: TimelinePostTableViewCell.self)) + tableView.register(TimelineMiddleLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + + return tableView + }() + + let refreshControl = UIRefreshControl() + + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } +} + +extension HomeTimelineViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + title = "Home" + view.backgroundColor = Asset.Colors.Background.systemBackground.color + navigationItem.leftBarButtonItem = avatarBarButtonItem + avatarBarButtonItem.avatarButton.addTarget(self, action: #selector(HomeTimelineViewController.avatarButtonPressed(_:)), for: .touchUpInside) + + tableView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(HomeTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged) + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + viewModel.tableView = tableView + viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self + tableView.delegate = self + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self, + timelinePostTableViewCellDelegate: self, + timelineMiddleLoaderTableViewCellDelegate: self + ) + + // bind refresh control + viewModel.isFetchingLatestTimeline + .receive(on: DispatchQueue.main) + .sink { [weak self] isFetching in + guard let self = self else { return } + if !isFetching { + UIView.animate(withDuration: 0.5) { [weak self] in + guard let self = self else { return } + self.refreshControl.endRefreshing() + } + } + } + .store(in: &disposeBag) + + #if DEBUG + avatarBarButtonItem.avatarButton.menu = debugMenu + avatarBarButtonItem.avatarButton.showsMenuAsPrimaryAction = true + #endif + + Publishers.CombineLatest( + context.authenticationService.activeMastodonAuthentication.eraseToAnyPublisher(), + viewModel.viewDidAppear.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] activeMastodonAuthentication, _ in + guard let self = self else { return } + guard let user = activeMastodonAuthentication?.user, + let avatarImageURL = user.avatarImageURL() else { + let input = AvatarConfigurableViewConfiguration.Input(avatarImageURL: nil) + self.avatarBarButtonItem.configure(withConfigurationInput: input) + return + } + let input = AvatarConfigurableViewConfiguration.Input(avatarImageURL: avatarImageURL) + self.avatarBarButtonItem.configure(withConfigurationInput: input) + } + .store(in: &disposeBag) + } + + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + viewModel.viewDidAppear.send() + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if (self.viewModel.fetchedResultsController.fetchedObjects ?? []).count == 0 { + self.viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) + } + } + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate { _ in + // do nothing + } completion: { _ in + // fix AutoLayout cell height not update after rotate issue + self.viewModel.cellFrameCache.removeAllObjects() + self.tableView.reloadData() + } + } +} + +extension HomeTimelineViewController { + + @objc private func avatarButtonPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + } + + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { + guard viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) else { + sender.endRefreshing() + return + } + } + + +} + +// MARK: - UIScrollViewDelegate +extension HomeTimelineViewController { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard scrollView === tableView else { return } + let cells = tableView.visibleCells.compactMap { $0 as? TimelineBottomLoaderTableViewCell } + guard let loaderTableViewCell = cells.first else { return } + + if let tabBar = tabBarController?.tabBar, let window = view.window { + let loaderTableViewCellFrameInWindow = tableView.convert(loaderTableViewCell.frame, to: nil) + let windowHeight = window.frame.height + let loaderAppear = (loaderTableViewCellFrameInWindow.origin.y + 0.8 * loaderTableViewCell.frame.height) < (windowHeight - tabBar.frame.height) + if loaderAppear { + viewModel.loadoldestStateMachine.enter(HomeTimelineViewModel.LoadOldestState.Loading.self) + } + } else { + viewModel.loadoldestStateMachine.enter(HomeTimelineViewModel.LoadOldestState.Loading.self) + } + } +} + +// MARK: - UITableViewDelegate +extension HomeTimelineViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } + + guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { + return 200 + } + // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription) + + return ceil(frame.height) + } +} + +// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate +extension HomeTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { + func navigationBar() -> UINavigationBar? { + return navigationController?.navigationBar + } +} + + +// MARK: - TimelineMiddleLoaderTableViewCellDelegate +extension HomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { + func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?, timelineIndexobjectID: NSManagedObjectID?) { + guard let upperTimelineIndexObjectID = timelineIndexobjectID else { + return + } + viewModel.loadMiddleSateMachineList + .receive(on: DispatchQueue.main) + .sink { [weak self] ids in + guard let self = self else { return } + if let stateMachine = ids[upperTimelineIndexObjectID] { + guard let state = stateMachine.currentState else { + assertionFailure() + return + } + + // make success state same as loading due to snapshot updating delay + let isLoading = state is HomeTimelineViewModel.LoadMiddleState.Loading || state is HomeTimelineViewModel.LoadMiddleState.Success + cell.loadMoreButton.isHidden = isLoading + if isLoading { + cell.activityIndicatorView.startAnimating() + } else { + cell.activityIndicatorView.stopAnimating() + } + } else { + cell.loadMoreButton.isHidden = false + cell.activityIndicatorView.stopAnimating() + } + } + .store(in: &cell.disposeBag) + + var dict = viewModel.loadMiddleSateMachineList.value + if let _ = dict[upperTimelineIndexObjectID] { + // do nothing + } else { + let stateMachine = GKStateMachine(states: [ + HomeTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID), + HomeTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID), + HomeTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID), + HomeTimelineViewModel.LoadMiddleState.Success(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID), + ]) + stateMachine.enter(HomeTimelineViewModel.LoadMiddleState.Initial.self) + dict[upperTimelineIndexObjectID] = stateMachine + viewModel.loadMiddleSateMachineList.value = dict + } + } + + func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + + switch item { + case .homeMiddleLoader(let upper): + guard let stateMachine = viewModel.loadMiddleSateMachineList.value[upper] else { + assertionFailure() + return + } + stateMachine.enter(HomeTimelineViewModel.LoadMiddleState.Loading.self) + default: + assertionFailure() + } + } +} + +// MARK: - ScrollViewContainer +extension HomeTimelineViewController: ScrollViewContainer { + + var scrollView: UIScrollView { return tableView } + + func scrollToTop(animated: Bool) { + if scrollView.contentOffset.y < scrollView.frame.height, + viewModel.loadLatestStateMachine.canEnterState(HomeTimelineViewModel.LoadLatestState.Loading.self), + (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) == 0.0, + !refreshControl.isRefreshing { + scrollView.scrollRectToVisible(CGRect(origin: CGPoint(x: 0, y: -refreshControl.frame.height), size: CGSize(width: 1, height: 1)), animated: animated) + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.refreshControl.beginRefreshing() + self.refreshControl.sendActions(for: .valueChanged) + } + } else { + let indexPath = IndexPath(row: 0, section: 0) + guard viewModel.diffableDataSource?.itemIdentifier(for: indexPath) != nil else { return } + tableView.scrollToRow(at: indexPath, at: .top, animated: true) + } + } + +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift new file mode 100644 index 000000000..5a63fe26a --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -0,0 +1,168 @@ +// +// HomeTimelineViewModel+Diffable.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/7. +// + +import os.log +import UIKit +import CoreData +import CoreDataStack + +extension HomeTimelineViewModel { + + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + timelinePostTableViewCellDelegate: TimelinePostTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate + ) { + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + diffableDataSource = TimelineSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: fetchedResultsController.managedObjectContext, + timestampUpdatePublisher: timestampUpdatePublisher, + timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate + ) + } + +} + +// MARK: - NSFetchedResultsControllerDelegate +extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { + + func controllerWillChangeContent(_ controller: NSFetchedResultsController) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + guard let tableView = self.tableView else { return } + guard let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return } + + guard let diffableDataSource = self.diffableDataSource else { return } + let oldSnapshot = diffableDataSource.snapshot() + + let predicate = fetchedResultsController.fetchRequest.predicate + let parentManagedObjectContext = fetchedResultsController.managedObjectContext + let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + managedObjectContext.parent = parentManagedObjectContext + + managedObjectContext.perform { + var shouldAddBottomLoader = false + + let timelineIndexes: [HomeTimelineIndex] = { + let request = HomeTimelineIndex.sortedFetchRequest + request.returnsObjectsAsFaults = false + request.predicate = predicate + do { + return try managedObjectContext.fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return [] + } + }() + + // that's will be the most fastest fetch because of upstream just update and no modify needs consider + + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.Attribute] = [:] + + for item in oldSnapshot.itemIdentifiers { + guard case let .homeTimelineIndex(objectID, attribute) = item else { continue } + oldSnapshotAttributeDict[objectID] = attribute + } + + var newTimelineItems: [Item] = [] + + for (i, timelineIndex) in timelineIndexes.enumerated() { + let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.Attribute() + + // append new item into snapshot + newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute)) + + let isLast = i == timelineIndexes.count - 1 + switch (isLast, timelineIndex.hasMore) { + case (true, false): + attribute.separatorLineStyle = .normal + case (false, true): + attribute.separatorLineStyle = .expand + newTimelineItems.append(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: timelineIndex.objectID)) + case (true, true): + attribute.separatorLineStyle = .normal + shouldAddBottomLoader = true + case (false, false): + attribute.separatorLineStyle = .indent + } + } // end for + + var newSnapshot = NSDiffableDataSourceSnapshot() + newSnapshot.appendSections([.main]) + newSnapshot.appendItems(newTimelineItems, toSection: .main) + + let endSnapshot = CACurrentMediaTime() + + DispatchQueue.main.async { + if shouldAddBottomLoader, !(self.loadoldestStateMachine.currentState is LoadOldestState.NoMore) { + newSnapshot.appendItems([.bottomLoader], toSection: .main) + } + + guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else { + diffableDataSource.apply(newSnapshot) + self.isFetchingLatestTimeline.value = false + return + } + + diffableDataSource.apply(newSnapshot, animatingDifferences: false) { + tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) + tableView.contentOffset.y = tableView.contentOffset.y - difference.offset + self.isFetchingLatestTimeline.value = false + } + + let end = CACurrentMediaTime() + os_log("%{public}s[%{public}ld], %{public}s: calculate home timeline layout cost %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - endSnapshot) + } + } // end perform + } + + private struct Difference { + let item: T + let sourceIndexPath: IndexPath + let targetIndexPath: IndexPath + let offset: CGFloat + } + + private func calculateReloadSnapshotDifference( + navigationBar: UINavigationBar, + tableView: UITableView, + oldSnapshot: NSDiffableDataSourceSnapshot, + newSnapshot: NSDiffableDataSourceSnapshot + ) -> Difference? { + guard oldSnapshot.numberOfItems != 0 else { return nil } + + // old snapshot not empty. set source index path to first item if not match + let sourceIndexPath = UIViewController.topVisibleTableViewCellIndexPath(in: tableView, navigationBar: navigationBar) ?? IndexPath(row: 0, section: 0) + + guard sourceIndexPath.row < oldSnapshot.itemIdentifiers(inSection: .main).count else { return nil } + + let timelineItem = oldSnapshot.itemIdentifiers(inSection: .main)[sourceIndexPath.row] + guard let itemIndex = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: timelineItem) else { return nil } + let targetIndexPath = IndexPath(row: itemIndex, section: 0) + + let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: sourceIndexPath, navigationBar: navigationBar) + return Difference( + item: timelineItem, + sourceIndexPath: sourceIndexPath, + targetIndexPath: targetIndexPath, + offset: offset + ) + } + +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift new file mode 100644 index 000000000..63300011d --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift @@ -0,0 +1,122 @@ +// +// HomeTimelineViewModel+LoadLatestState.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/5. +// + +import os.log +import func QuartzCore.CACurrentMediaTime +import Foundation +import CoreData +import CoreDataStack +import GameplayKit + +extension HomeTimelineViewModel { + class LoadLatestState: GKState { + weak var viewModel: HomeTimelineViewModel? + + init(viewModel: HomeTimelineViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + viewModel?.loadLatestStateMachinePublisher.send(self) + } + } +} + +extension HomeTimelineViewModel.LoadLatestState { + class Initial: HomeTimelineViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class Loading: HomeTimelineViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Idle.self + } + + 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 { + assertionFailure() + stateMachine.enter(Fail.self) + return + } + + let predicate = viewModel.fetchedResultsController.fetchRequest.predicate + let parentManagedObjectContext = viewModel.fetchedResultsController.managedObjectContext + let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + managedObjectContext.parent = parentManagedObjectContext + + managedObjectContext.perform { + let start = CACurrentMediaTime() + let latestTootIDs: [Toot.ID] + let request = HomeTimelineIndex.sortedFetchRequest + request.returnsObjectsAsFaults = false + request.predicate = predicate + + do { + let timelineIndexes = try managedObjectContext.fetch(request) + let endFetch = CACurrentMediaTime() + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect timelineIndexes cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, endFetch - start) + latestTootIDs = timelineIndexes + .prefix(200) // avoid performance issue + .compactMap { timelineIndex in + timelineIndex.value(forKeyPath: #keyPath(HomeTimelineIndex.toot.id)) as? Toot.ID + } + } catch { + stateMachine.enter(Fail.self) + return + } + let end = CACurrentMediaTime() + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect toots id cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start) + + // TODO: only set large count when using Wi-Fi + viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + // TODO: handle error + viewModel.isFetchingLatestTimeline.value = false + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + + stateMachine.enter(Idle.self) + + } receiveValue: { response in + // stop refresher if no new toots + let toots = response.value + let newToots = toots.filter { !latestTootIDs.contains($0.id) } + os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld new toots", ((#file as NSString).lastPathComponent), #line, #function, newToots.count) + + if newToots.isEmpty { + viewModel.isFetchingLatestTimeline.value = false + } + } + .store(in: &viewModel.disposeBag) + } + } + } + + class Fail: HomeTimelineViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self || stateClass == Idle.self + } + } + + class Idle: HomeTimelineViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift new file mode 100644 index 000000000..5a212f357 --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift @@ -0,0 +1,107 @@ +// +// HomeTimelineViewModel+LoadMiddleState.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/5. +// + +import os.log +import Foundation +import GameplayKit +import CoreData +import CoreDataStack + +extension HomeTimelineViewModel { + class LoadMiddleState: GKState { + weak var viewModel: HomeTimelineViewModel? + let upperTimelineIndexObjectID: NSManagedObjectID + + init(viewModel: HomeTimelineViewModel, upperTimelineIndexObjectID: NSManagedObjectID) { + self.viewModel = viewModel + self.upperTimelineIndexObjectID = upperTimelineIndexObjectID + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + var dict = viewModel.loadMiddleSateMachineList.value + dict[upperTimelineIndexObjectID] = stateMachine + viewModel.loadMiddleSateMachineList.value = dict // trigger value change + } + } +} + +extension HomeTimelineViewModel.LoadMiddleState { + + class Initial: HomeTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class Loading: HomeTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // guard let viewModel = viewModel else { return false } + return stateClass == Success.self || stateClass == Fail.self + } + + 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 + } + + guard let timelineIndex = (viewModel.fetchedResultsController.fetchedObjects ?? []).first(where: { $0.objectID == upperTimelineIndexObjectID }) else { + stateMachine.enter(Fail.self) + return + } + let tootIDs = (viewModel.fetchedResultsController.fetchedObjects ?? []).compactMap { timelineIndex in + timelineIndex.toot.id + } + + // TODO: only set large count when using Wi-Fi + let maxID = timelineIndex.toot.id + viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain,maxID: maxID, authorizationBox: activeMastodonAuthenticationBox) + .delay(for: .seconds(1), scheduler: DispatchQueue.main) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + // TODO: handle error + os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + stateMachine.enter(Fail.self) + case .finished: + break + } + } receiveValue: { response in + let toots = response.value + let newToots = toots.filter { !tootIDs.contains($0.id) } + os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld toots, %{public}%ld new toots", ((#file as NSString).lastPathComponent), #line, #function, toots.count, newToots.count) + if newToots.isEmpty { + stateMachine.enter(Fail.self) + } else { + stateMachine.enter(Success.self) + } + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: HomeTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // guard let viewModel = viewModel else { return false } + return stateClass == Loading.self + } + } + + class Success: HomeTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // guard let viewModel = viewModel else { return false } + return false + } + } + +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift new file mode 100644 index 000000000..c6eb988b3 --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift @@ -0,0 +1,110 @@ +// +// HomeTimelineViewModel+LoadOldestState.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/5. +// + +import os.log +import Foundation +import GameplayKit + +extension HomeTimelineViewModel { + class LoadOldestState: GKState { + weak var viewModel: HomeTimelineViewModel? + + init(viewModel: HomeTimelineViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + viewModel?.loadOldestStateMachinePublisher.send(self) + } + } +} + +extension HomeTimelineViewModel.LoadOldestState { + class Initial: HomeTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let viewModel = viewModel else { return false } + guard !(viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false } + return stateClass == Loading.self + } + } + + class Loading: HomeTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self + } + + 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 { + assertionFailure() + stateMachine.enter(Fail.self) + return + } + + guard let last = viewModel.fetchedResultsController.fetchedObjects?.last else { + stateMachine.enter(Idle.self) + return + } + + // TODO: only set large count when using Wi-Fi + let maxID = last.toot.id + viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain, maxID: maxID, authorizationBox: activeMastodonAuthenticationBox) + .delay(for: .seconds(1), scheduler: DispatchQueue.main) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + } receiveValue: { response in + let toots = response.value + // enter no more state when no new toots + if toots.isEmpty || (toots.count == 1 && toots[0].id == maxID) { + stateMachine.enter(NoMore.self) + } else { + stateMachine.enter(Idle.self) + } + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: HomeTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self || stateClass == Idle.self + } + } + + class Idle: HomeTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class NoMore: HomeTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // reset state if needs + return stateClass == Idle.self + } + + override func didEnter(from previousState: GKState?) { + guard let viewModel = viewModel else { return } + guard let diffableDataSource = viewModel.diffableDataSource else { + assertionFailure() + return + } + var snapshot = diffableDataSource.snapshot() + snapshot.deleteItems([.bottomLoader]) + diffableDataSource.apply(snapshot) + } + } +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift new file mode 100644 index 000000000..5ccfca2fd --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -0,0 +1,129 @@ +// +// HomeTimelineViewModel.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/5. +// + +import os.log +import func AVFoundation.AVMakeRect +import UIKit +import AVKit +import Combine +import CoreData +import CoreDataStack +import GameplayKit +import AlamofireImage +import DateToolsSwift +import ActiveLabel + +final class HomeTimelineViewModel: NSObject { + + var disposeBag = Set() + var observations = Set() + + // input + let context: AppContext + let timelinePredicate = CurrentValueSubject(nil) + let fetchedResultsController: NSFetchedResultsController + let isFetchingLatestTimeline = CurrentValueSubject(false) + let viewDidAppear = PassthroughSubject() + + weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? + weak var tableView: UITableView? + weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? + + // output + // top loader + private(set) lazy var loadLatestStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + LoadLatestState.Initial(viewModel: self), + LoadLatestState.Loading(viewModel: self), + LoadLatestState.Fail(viewModel: self), + LoadLatestState.Idle(viewModel: self), + ]) + stateMachine.enter(LoadLatestState.Initial.self) + return stateMachine + }() + lazy var loadLatestStateMachinePublisher = CurrentValueSubject(nil) + // bottom loader + private(set) lazy var loadoldestStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + LoadOldestState.Initial(viewModel: self), + LoadOldestState.Loading(viewModel: self), + LoadOldestState.Fail(viewModel: self), + LoadOldestState.Idle(viewModel: self), + LoadOldestState.NoMore(viewModel: self), + ]) + stateMachine.enter(LoadOldestState.Initial.self) + return stateMachine + }() + lazy var loadOldestStateMachinePublisher = CurrentValueSubject(nil) + // middle loader + let loadMiddleSateMachineList = CurrentValueSubject<[NSManagedObjectID: GKStateMachine], Never>([:]) // TimelineIndex.objectID : middle loading state machine + var diffableDataSource: UITableViewDiffableDataSource? + var cellFrameCache = NSCache() + + + init(context: AppContext) { + self.context = context + self.fetchedResultsController = { + let fetchRequest = HomeTimelineIndex.sortedFetchRequest + fetchRequest.fetchBatchSize = 20 + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(HomeTimelineIndex.toot)] + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: context.managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + return controller + }() + super.init() + + fetchedResultsController.delegate = self + + timelinePredicate + .receive(on: DispatchQueue.main) + .compactMap { $0 } + .first() // set once + .sink { [weak self] predicate in + guard let self = self else { return } + self.fetchedResultsController.fetchRequest.predicate = predicate + do { + self.diffableDataSource?.defaultRowAnimation = .fade + try self.fetchedResultsController.performFetch() + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + guard let self = self else { return } + self.diffableDataSource?.defaultRowAnimation = .automatic + } + } catch { + assertionFailure(error.localizedDescription) + } + } + .store(in: &disposeBag) + + context.authenticationService.activeMastodonAuthentication + .sink { [weak self] activeMastodonAuthentication in + guard let self = self else { return } + guard let twitterAuthentication = activeMastodonAuthentication else { return } + let activeTwitterUserID = twitterAuthentication.userID + let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + HomeTimelineIndex.predicate(userID: activeTwitterUserID), + HomeTimelineIndex.notDeleted() + ]) + self.timelinePredicate.value = predicate + } + .store(in: &disposeBag) + + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } + +} diff --git a/Mastodon/Scene/HomeTimeline/HomeViewController.swift b/Mastodon/Scene/HomeTimeline/HomeViewController.swift deleted file mode 100644 index afcd7def0..000000000 --- a/Mastodon/Scene/HomeTimeline/HomeViewController.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// HomeViewController.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021/1/27. -// - -import os.log -import UIKit -import Combine - -final class HomeViewController: UIViewController, NeedsDependency { - - var disposeBag = Set() - - weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } - weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } - - var viewModel: HomeViewModel! - - let avatarBarButtonItem = AvatarBarButtonItem() - -} - -extension HomeViewController { - - override func viewDidLoad() { - super.viewDidLoad() - - title = "Home" - view.backgroundColor = Asset.Colors.Background.systemBackground.color - navigationItem.leftBarButtonItem = avatarBarButtonItem - avatarBarButtonItem.avatarButton.addTarget(self, action: #selector(HomeViewController.avatarBarButtonItemDidPressed(_:)), for: .touchUpInside) - #if DEBUG - avatarBarButtonItem.avatarButton.menu = debugMenu - avatarBarButtonItem.avatarButton.showsMenuAsPrimaryAction = true - #endif - - Publishers.CombineLatest( - context.authenticationService.activeMastodonAuthentication.eraseToAnyPublisher(), - viewModel.viewDidAppear.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] activeMastodonAuthentication, _ in - guard let self = self else { return } - guard let user = activeMastodonAuthentication?.user, - let avatarImageURL = user.avatarImageURL() else { - let input = AvatarConfigurableViewConfiguration.Input(avatarImageURL: nil) - self.avatarBarButtonItem.configure(withConfigurationInput: input) - return - } - let input = AvatarConfigurableViewConfiguration.Input(avatarImageURL: avatarImageURL) - self.avatarBarButtonItem.configure(withConfigurationInput: input) - } - .store(in: &disposeBag) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - viewModel.viewDidAppear.send() - } - -} - -extension HomeViewController { - @objc private func avatarBarButtonItemDidPressed(_ sender: UIBarButtonItem) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - - } -} diff --git a/Mastodon/Scene/HomeTimeline/HomeViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeViewModel.swift deleted file mode 100644 index b6694f114..000000000 --- a/Mastodon/Scene/HomeTimeline/HomeViewModel.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// HomeViewModel.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-2-4. -// - -import UIKit -import Combine - -final class HomeViewModel { - - // input - let context: AppContext - let viewDidAppear = PassthroughSubject() - - // output - - init(context: AppContext) { - self.context = context - } - -} - diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/MainTab/MainTabBarController.swift index b0e50465f..cc39cd9fc 100644 --- a/Mastodon/Scene/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/MainTab/MainTabBarController.swift @@ -39,8 +39,7 @@ class MainTabBarController: UITabBarController { let viewController: UIViewController switch self { case .home: - let _viewController = HomeViewController() - _viewController.viewModel = HomeViewModel(context: context) + let _viewController = HomeTimelineViewController() _viewController.context = context _viewController.coordinator = coordinator viewController = _viewController diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index 666ed4272..fd72a18c1 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -142,8 +142,8 @@ extension PublicTimelineViewController: LoadMoreConfigurableTableViewContainer { // MARK: - TimelineMiddleLoaderTableViewCellDelegate extension PublicTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { - - func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String) { + func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?, timelineIndexobjectID: NSManagedObjectID?) { + guard let upperTimelineTootID = upperTimelineTootID else {return} viewModel.loadMiddleSateMachineList .receive(on: DispatchQueue.main) .sink { [weak self] ids in @@ -191,7 +191,7 @@ extension PublicTimelineViewController: TimelineMiddleLoaderTableViewCellDelegat guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } switch item { - case .middleLoader(let upper): + case .publicMiddleLoader(let upper): guard let stateMachine = viewModel.loadMiddleSateMachineList.value[upper] else { assertionFailure() return diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift index 9ec745daf..b3e8b27f6 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift @@ -54,7 +54,7 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate { for tuple in indexTootTuples { items.append(Item.toot(objectID: tuple.1.objectID)) if tootIDsWhichHasGap.contains(tuple.1.id) { - items.append(Item.middleLoader(tootID: tuple.1.id)) + items.append(Item.publicMiddleLoader(tootID: tuple.1.id)) } } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift index cdaddfe7d..62334c746 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift @@ -60,7 +60,7 @@ extension PublicTimelineViewModel.LoadMiddleState { .sink { completion in switch completion { case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: fetch tweets failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) stateMachine.enter(Fail.self) case .finished: break @@ -88,7 +88,7 @@ extension PublicTimelineViewModel.LoadMiddleState { } viewModel.tootIDs.value = newTootIDs - os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld tweets, %{public}%ld new tweets", (#file as NSString).lastPathComponent, #line, #function, toots.count, addedToots.count) + os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld toots, %{public}%ld new toots", (#file as NSString).lastPathComponent, #line, #function, toots.count, addedToots.count) if addedToots.isEmpty { stateMachine.enter(Fail.self) } else { diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift index 8cdd7d838..28ffc35a7 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift @@ -11,7 +11,7 @@ import os.log import UIKit protocol TimelineMiddleLoaderTableViewCellDelegate: class { - func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String) + func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?,timelineIndexobjectID:NSManagedObjectID?) func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) } diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift index 259c796a6..128da56e3 100644 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift @@ -421,7 +421,8 @@ extension APIService.Persist { let timelineIndex = status.homeTimelineIndexes? .first { $0.userID == requestMastodonUserID } if timelineIndex == nil { - let timelineIndexProperty = HomeTimelineIndex.Property(domain: domain) + let timelineIndexProperty = HomeTimelineIndex.Property(domain: domain,userID: requestMastodonUserID) + let _ = HomeTimelineIndex.insert( into: managedObjectContext, property: timelineIndexProperty, diff --git a/Mastodon/Vender/ControlContainableScrollViews.swift b/Mastodon/Vender/ControlContainableScrollViews.swift new file mode 100644 index 000000000..057527ce2 --- /dev/null +++ b/Mastodon/Vender/ControlContainableScrollViews.swift @@ -0,0 +1,63 @@ +// Ref: https://www.rightpoint.com/rplabs/fixing-controls-and-scrolling-button-views-ios + +import UIKit + +// These let you start a touch on a control that's inside a scroll view, +// and then if you start dragging, it cancels the touch on the button +// and lets you scroll instead. Without these scroll view subclasses, +// controls in scroll views will eat touches that start in them, which +// prevents scrolling and makes the app feel broken. +// +// The UITextInput exception is for cases where you have a text field +// or a text view in a scroll view. If you press and hold there, you want +// to get the text editing magnifier cursor, instead of canceling the +// touch in the text input element. +// +// Ditto for UISlider and UISwitch: if the table view eats the drag gesture, +// they feel broken. Feel free to add your own exceptions if you have custom +// controls that require swiping or dragging to function. + +final class ControlContainableScrollView: UIScrollView { + + override func touchesShouldCancel(in view: UIView) -> Bool { + if view is UIControl + && !(view is UITextInput) + && !(view is UISlider) + && !(view is UISwitch) { + return true + } + + return super.touchesShouldCancel(in: view) + } + +} + +final class ControlContainableTableView: UITableView { + + override func touchesShouldCancel(in view: UIView) -> Bool { + if view is UIControl + && !(view is UITextInput) + && !(view is UISlider) + && !(view is UISwitch) { + return true + } + + return super.touchesShouldCancel(in: view) + } + +} + +final class ControlContainableCollectionView: UICollectionView { + + override func touchesShouldCancel(in view: UIView) -> Bool { + if view is UIControl + && !(view is UITextInput) + && !(view is UISlider) + && !(view is UISwitch) { + return true + } + + return super.touchesShouldCancel(in: view) + } + +}